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    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3220    /// instead of a bearer capability.
3221    #[cfg(unix)]
3222    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3223    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3224    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3225    managed_spawn_replays:
3226        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3227    controller: Mutex<Option<ControllerLease>>,
3228    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3229    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3230    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3231    /// ACP launch options waiting for the agent to advertise its config options.
3232    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3233    history: Mutex<NodeEventHistory>,
3234    event_tx: broadcast::Sender<NodeEventEnvelope>,
3235    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3236    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3237    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3238    /// exactly rather than riding the durable `event_tx` history (see
3239    /// `publish`'s own assertion): a subscriber who never negotiated
3240    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3241    /// these, the same guarantee `include_terminal_frame_events` already
3242    /// gives terminal frames.
3243    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3244    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3245    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3246    /// broadcast rather than a ride on the durable `event_tx` history. A
3247    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3248    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3249    /// finite capacity and its willingness to silently evict a lagging
3250    /// subscriber's backlog is exactly how the call vanished with neither
3251    /// side ever logging it (see `publish_transient`'s own doc).
3252    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3253    terminal_frame_watermarks:
3254        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3255    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3256    pty_input_wake: tokio::sync::Notify,
3257    /// Per session: when the last PTY input was dispatched (latency probe +
3258    /// a short fast-poll window so the echo is published within ~1ms).
3259    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3260    next_connection_id: AtomicU64,
3261    next_instance_id: AtomicU64,
3262    next_command_id: AtomicU64,
3263    shutdown: AtomicBool,
3264    shutdown_notify: Notify,
3265    preauth_slots: Arc<Semaphore>,
3266    authenticated_slots: Arc<Semaphore>,
3267    inspection_slots: Arc<Semaphore>,
3268    mutation_gate: AsyncMutex<()>,
3269    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3270    session_records: Mutex<SessionRecords>,
3271    state_path: Option<PathBuf>,
3272    persistence_error: RwLock<Option<String>>,
3273    state_transaction: Mutex<()>,
3274    input_settle_timeout_ms: u64,
3275    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3276    /// comment. Written once per iteration by
3277    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3278    drive_loop_profile: Mutex<DriveLoopProfiler>,
3279    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3280    /// so an overwrite each iteration is cheap. The runtime itself is not
3281    /// reachable from here (it is owned locally by
3282    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3283    /// tick timings reach `GET /metrics`.
3284    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3285    /// The live shell-efficiency profile, shared with the per-instance
3286    /// worker loops that fold into it.
3287    ///
3288    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3289    /// profile` above keeps: producing a snapshot sorts every ring behind
3290    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3291    /// field set at construction because `NodeShared` is built before the
3292    /// `NativeRuntime` that owns the profile exists -- the drive loop
3293    /// publishes it once, before its first iteration. Unset means the
3294    /// runtime has not started driving yet, which `GET /metrics` reports as
3295    /// empty series rather than as zeroes it never measured.
3296    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3297    /// Physical PTY sessions the native runtime currently owns
3298    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3299    /// loop iteration -- deliberately a separate count from
3300    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3301    /// session bookkeeping), since the two can disagree and the gap is
3302    /// itself a diagnostic signal `GET /metrics` exposes.
3303    native_session_gauge: AtomicUsize,
3304    /// Lifetime totals for the per-connection serve loop, summed across
3305    /// every live connection.
3306    ///
3307    /// The drive loop's own phase timings account for about ten
3308    /// microseconds an iteration on an idle node, yet the process burns a
3309    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3310    /// with none, 23% with two, measured). That gap is somewhere in
3311    /// serving a connection, and there was no way to see it: iterations
3312    /// says whether the loop is spinning rather than sleeping, and events
3313    /// says whether it is spinning over real work or over nothing.
3314    connection_loop_iterations: AtomicU64,
3315    connection_events_sent: AtomicU64,
3316    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3317    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3318    /// `NativeRuntime::tick` observations AND forwarded zero control
3319    /// events; see the exact "produced nothing" definition at the site in
3320    /// `drive_runtime_until_shutdown` that increments these. Read twice
3321    /// and divide by the interval, same rule as `connections` above --
3322    /// `idle / total` is backlog item 5's "wake on work" number.
3323    /// Lifetime count of control commands the kernel refused.
3324    rejected_commands_total: AtomicU64,
3325    /// The named reason behind a recent kernel rejection, by the
3326    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3327    /// command -- populated by `drive_runtime_until_shutdown` for every
3328    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3329    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3330    /// `CommandId` among them.
3331    ///
3332    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3333    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3334    /// problem -- full or disconnected -- never for the kernel refusing the
3335    /// command's content (an unsupported transport, an unknown agent, ...);
3336    /// that verdict exists only once `NativeRuntime::tick` has processed the
3337    /// command, one drive-loop iteration later, in a different task than
3338    /// whichever caller is awaiting the spawn. Without this map, a spawn
3339    /// wait loop that polls `self.handle.snapshot()` for a session that
3340    /// will now never appear has no way to learn why -- it can only spin
3341    /// until its own commit deadline and report a generic "did not commit"
3342    /// timeout, discarding a reason the kernel already named precisely (see
3343    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3344    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3345    drive_loop_iterations_total: AtomicU64,
3346    drive_loop_iterations_idle: AtomicU64,
3347    /// The operator-chosen age/keep-N policy the retention sweep applies to
3348    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3349    /// see `SessionRecordRetentionConfig`'s own doc comment.
3350    session_record_retention: SessionRecordRetentionConfig,
3351    /// Lifetime count of managed session records the retention sweep has
3352    /// retired. Same tier as `rejected_commands_total` above: a live,
3353    /// process-lifetime instrument, never persisted and never reloaded from
3354    /// `state-v1.json` -- a restart legitimately resets it, matching every
3355    /// other `*_total` counter here.
3356    retired_records_total: AtomicU64,
3357    #[cfg(feature = "fixture")]
3358    fixture_semantic_hook_policy: bool,
3359    #[cfg(feature = "fixture")]
3360    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3361}
3362
3363impl Drop for NodeShared {
3364    fn drop(&mut self) {
3365        SessionLifecycleHook::on_shutdown(self);
3366    }
3367}
3368
3369#[derive(Clone, Debug, Eq, PartialEq)]
3370struct SessionBinding {
3371    workspace_id: WorkspaceId,
3372    generation: SessionGeneration,
3373    runtime_policy: ProviderRuntimePolicy,
3374    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3375    record_id: Option<SessionRecordId>,
3376    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3377    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3378    bundle: Option<ResolvedBundleReceipt>,
3379    context: Option<ResolvedContextPackReceipt>,
3380    materialization_id: Option<MaterializationId>,
3381}
3382
3383#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3384enum SpawnRecordPolicy {
3385    ProviderIdentityOnly,
3386    Always,
3387}
3388
3389struct NativeEnvironmentSelectionGuard {
3390    control: Option<NativeLaunchProfileControl>,
3391    instance_id: AgentInstanceId,
3392}
3393
3394struct NativeInstanceOverlayGuard {
3395    control: Option<NativeLaunchProfileControl>,
3396    instance_id: AgentInstanceId,
3397}
3398
3399struct NativeHarnessMcpOverlayGuard {
3400    control: NativeLaunchProfileControl,
3401    instance_id: AgentInstanceId,
3402}
3403
3404impl NativeHarnessMcpOverlayGuard {
3405    fn retain(self) { std::mem::forget(self); }
3406}
3407
3408impl Drop for NativeHarnessMcpOverlayGuard {
3409    fn drop(&mut self) {
3410        self.control
3411            .clear_native_mcp_server_launch_overlay(self.instance_id);
3412    }
3413}
3414
3415impl NativeInstanceOverlayGuard {
3416    fn retain(mut self) {
3417        self.control = None;
3418    }
3419}
3420
3421impl Drop for NativeInstanceOverlayGuard {
3422    fn drop(&mut self) {
3423        if let Some(control) = self.control.as_ref() {
3424            control.clear_native_instance_launch_overlay(self.instance_id);
3425        }
3426    }
3427}
3428
3429enum PreparedNativeLaunchOverlay {
3430    Environment(NativeLaunchEnvironmentOverlay),
3431    Instance(NativeInstanceLaunchOverlay),
3432}
3433
3434impl NativeEnvironmentSelectionGuard {
3435    fn retain(mut self) {
3436        self.control = None;
3437    }
3438}
3439
3440impl Drop for NativeEnvironmentSelectionGuard {
3441    fn drop(&mut self) {
3442        if let Some(control) = self.control.as_ref() {
3443            control.clear_native_launch_profile_selection(self.instance_id);
3444        }
3445    }
3446}
3447
3448struct SessionMaterializationGuard<'a> {
3449    shared: &'a NodeShared,
3450    id: Option<MaterializationId>,
3451    /// Absolute ProviderHome (`home/`) under the session materialization root.
3452    /// Used for Claude station network `--settings` sidecar writes.
3453    provider_home: Option<PathBuf>,
3454}
3455
3456impl SessionMaterializationGuard<'_> {
3457    fn id(&self) -> Option<&MaterializationId> {
3458        self.id.as_ref()
3459    }
3460
3461    fn provider_home(&self) -> Option<&Path> {
3462        self.provider_home.as_deref()
3463    }
3464
3465    fn retain(mut self) {
3466        self.id = None;
3467    }
3468}
3469
3470impl Drop for SessionMaterializationGuard<'_> {
3471    fn drop(&mut self) {
3472        if let Some(id) = self.id.take() {
3473            let _ = self.shared.cleanup_materialization(&id);
3474        }
3475    }
3476}
3477
3478
3479/// Port: product-side reactions to session lifecycle transitions
3480/// (station lease, harness MCP overlay/registry, environment launch overlays).
3481trait SessionLifecycleHook {
3482    /// A binding was removed (unbind or spawn rollback).
3483    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3484    /// A session reached a terminal control event that revokes its harness MCP door.
3485    fn on_session_ended(&self, address: &SessionAddress);
3486    /// Node shutdown (or drop): revoke every harness MCP reservation.
3487    fn on_shutdown(&self);
3488    /// A harness MCP reservation was aborted before activation.
3489    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3490    /// Release session-owned materialization for a removed binding.
3491    fn cleanup_session_materialization(
3492        &self,
3493        address: &SessionAddress,
3494        binding: &SessionBinding,
3495    ) -> Result<(), NodeFailure>;
3496}
3497
3498impl SessionLifecycleHook for NodeShared {
3499    fn on_session_ended(&self, address: &SessionAddress) {
3500        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3501            registry.revoke_session(address);
3502        }
3503        if let Some(control) = self.native_launch_profile_control.as_ref() {
3504            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3505        }
3506    }
3507
3508    fn on_shutdown(&self) {
3509        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3510            let instances = registry.shutdown();
3511            if let Some(control) = self.native_launch_profile_control.as_ref() {
3512                for instance_id in instances {
3513                    control.clear_native_mcp_server_launch_overlay(instance_id);
3514                }
3515            }
3516        }
3517    }
3518
3519    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3520        if let Some(control) = self.native_launch_profile_control.as_ref() {
3521            control.clear_native_mcp_server_launch_overlay(instance_id);
3522        }
3523    }
3524
3525    fn cleanup_session_materialization(
3526        &self,
3527        address: &SessionAddress,
3528        binding: &SessionBinding,
3529    ) -> Result<(), NodeFailure> {
3530        self.cleanup_session_owned_materialization(address, binding)
3531    }
3532
3533    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3534        // Exclusive dig2browser station profile lease is node-local; clear
3535        // on every session unbind / rollback. Never ImportSession/cookies.
3536        #[cfg(feature = "dig2-station-probe")]
3537        {
3538            crate::dig2_station_lease::release_lease_for_holder(
3539                &self.browser_station_leases,
3540                address.session.instance_id,
3541            );
3542        }
3543        if let Some(control) = self.native_launch_profile_control.as_ref() {
3544            control.clear_native_mcp_server_launch_overlay(
3545                address.session.instance_id,
3546            );
3547        }
3548        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549            registry.revoke_session(address);
3550        }
3551        if removed
3552            .is_some_and(|binding| {
3553                binding.environment_profile.is_some()
3554                    || binding.bundle.is_some()
3555                    || binding.context.is_some()
3556            })
3557        {
3558            if let Some(control) = self.native_launch_profile_control.as_ref() {
3559                control.clear_native_launch_profile_selection(
3560                    address.session.instance_id,
3561                );
3562                control.clear_native_instance_launch_overlay(
3563                    address.session.instance_id,
3564                );
3565            }
3566        }
3567    }
3568}
3569
3570struct SpawnPrepareRequest<'r> {
3571    instance_id: AgentInstanceId,
3572    address: &'r SessionAddress,
3573    provider: &'r AgentId,
3574    mode: SessionMode,
3575    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3576    bundle: Option<&'r ResolvedBundleReceipt>,
3577    context: Option<&'r ResolvedContextPackReceipt>,
3578    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3579    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3580    approval_level: ApprovalLevel,
3581    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3582    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3583}
3584
3585/// Guards held between "session identity minted" and "launch committed".
3586/// Field order is the drop order and matches the pre-refactor local order
3587/// (instance overlay, environment selection, materialization, harness, station lease).
3588struct PreparedSpawn<'a> {
3589    instance_overlay: Option<NativeInstanceOverlayGuard>,
3590    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3591    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3592    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3593    #[cfg(feature = "dig2-station-probe")]
3594    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3595    materialization_id: Option<MaterializationId>,
3596}
3597
3598impl PreparedSpawn<'_> {
3599    /// Session-owned launch state now belongs to the binding.
3600    fn retain_session_guards(&mut self) {
3601        if let Some(selection) = self.environment_selection.take() {
3602            selection.retain();
3603        }
3604        if let Some(overlay) = self.instance_overlay.take() {
3605            overlay.retain();
3606        }
3607        if let Some(guard) = self.materialization_guard.take() {
3608            guard.retain();
3609        }
3610    }
3611
3612    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3613    fn retain_launch_guards(&mut self) {
3614        if let Some(overlay) = self.harness_mcp_overlay.take() {
3615            overlay.retain();
3616        }
3617        #[cfg(feature = "dig2-station-probe")]
3618        if let Some(guard) = self.browser_station_lease_guard.take() {
3619            guard.retain();
3620        }
3621    }
3622}
3623
3624/// Port: everything product-side that must be prepared before a session is
3625/// bound and started (station lease, harness MCP overlay, materialization,
3626/// environment selection, network overlays, launch overlay).
3627trait SpawnPreparer {
3628    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3629    /// Select the native launch profile for an environment-profile session.
3630    fn select_environment(
3631        &self,
3632        instance_id: AgentInstanceId,
3633        provider: &AgentId,
3634        mode: SessionMode,
3635        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3636    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3637    /// Install a prepared launch overlay for the instance.
3638    fn install_launch_overlay(
3639        &self,
3640        instance_id: AgentInstanceId,
3641        overlay: PreparedNativeLaunchOverlay,
3642    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3643}
3644
3645impl SpawnPreparer for NodeShared {
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        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3654    }
3655
3656    fn install_launch_overlay(
3657        &self,
3658        instance_id: AgentInstanceId,
3659        overlay: PreparedNativeLaunchOverlay,
3660    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3661        self.install_prepared_launch_overlay(instance_id, overlay)
3662    }
3663
3664    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3665        let SpawnPrepareRequest {
3666            instance_id,
3667            address,
3668            provider,
3669            mode,
3670            environment_profile,
3671            bundle,
3672            context,
3673            managed_authority,
3674            harness_mcp,
3675            approval_level,
3676            network_allowlist,
3677            browser_profile_id,
3678        } = request;
3679        // Track A follow-on: exclusive node-local BrowserStationLease after
3680        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3681        // never ImportSession / cookies on C2. Linux/feature-off: probe
3682        // already refused or stub; this block is cfg'd out when feature off.
3683        #[cfg(feature = "dig2-station-probe")]
3684        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3685            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3686            match acquire_lease(
3687                &self.browser_station_leases,
3688                profile_id,
3689                instance_id,
3690                unix_time_ms(),
3691            ) {
3692                Ok(guard) => Some(guard),
3693                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3694                    return Err(failure(
3695                        NodeFailureCode::BrowserStationProfileBusy,
3696                        "dig2browser station profile is already leased by another session",
3697                    ));
3698                }
3699                Err(BrowserStationLeaseError::CapacityExceeded) => {
3700                    return Err(failure(
3701                        NodeFailureCode::BrowserStationProfileBusy,
3702                        "dig2browser station profile lease capacity exhausted",
3703                    ));
3704                }
3705                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3706                    return Err(failure(
3707                        NodeFailureCode::BrowserStationProfileBusy,
3708                        "session already holds a dig2browser station profile lease",
3709                    ));
3710                }
3711            }
3712        } else {
3713            None
3714        };
3715        #[cfg(not(feature = "dig2-station-probe"))]
3716        let _ = browser_profile_id;
3717        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3718            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3719            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3720            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3721            // both read the identical overlay resolved below, one as child
3722            // environment, the other translated into one stdio MCP server
3723            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3724            // only, and nothing downstream reads a harness-MCP overlay for
3725            // that transport yet.
3726            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3727            {
3728                return Err(failure(
3729                    NodeFailureCode::BindingMismatch,
3730                    "harness MCP reservation does not match the exact PTY/ACP provider",
3731                ));
3732            }
3733            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3734            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3735                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3736            })?;
3737            // Every name below is the caller's, carried in the reservation's
3738            // launch description: which environment variables receive the
3739            // endpoint, token and helper program, what the MCP server is called,
3740            // which argv it gets and which stale variables are erased.
3741            let launch = prepared.launch();
3742            let mut env_entries = vec![
3743                (
3744                    OsString::from(launch.endpoint_env.as_str()),
3745                    prepared.endpoint().as_os_str().to_os_string(),
3746                ),
3747                (
3748                    OsString::from(launch.token_env.as_str()),
3749                    prepared.token().expose().into(),
3750                ),
3751                (
3752                    OsString::from(launch.program_env.as_str()),
3753                    prepared.helper_program().as_os_str().to_os_string(),
3754                ),
3755            ];
3756            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3757                env_entries.push(trace_entry);
3758            }
3759            if let Some(door) = crate::door::door_listen_addr() {
3760                env_entries.push((
3761                    OsString::from("HATCHERY_NODE_DOOR"),
3762                    OsString::from(door.to_string()),
3763                ));
3764                // Same key shape as HQ browser/draft ids:
3765                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3766                // slot across node boots when the instance counter resets.
3767                let session_key = format!(
3768                    "pty-{}-{}-{}",
3769                    self.incarnation_id,
3770                    address.workspace_id.as_str(),
3771                    address.session.instance_id.0
3772                );
3773                env_entries.push((
3774                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3775                    OsString::from(session_key),
3776                ));
3777            }
3778            let overlay = NativeMcpServerLaunchOverlay::new(
3779                provider.clone(),
3780                launch.server_name.as_str(),
3781                prepared.helper_program().as_os_str().to_os_string(),
3782                launch.args.iter().map(OsString::from).collect(),
3783                env_entries,
3784                launch.scrub_env.iter().map(OsString::from).collect(),
3785            ).map_err(|_| failure(
3786                NodeFailureCode::HarnessMcpUnavailable,
3787                "harness MCP launch overlay is unavailable",
3788            ))?;
3789            control
3790                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3791                .map_err(|_| failure(
3792                    NodeFailureCode::HarnessMcpUnavailable,
3793                    "harness MCP launch overlay could not be installed",
3794                ))?;
3795            // State-change line: the overlay is installed and will be read
3796            // back by the PTY child's environment or, for ACP,
3797            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3798            // time -- no secrets here, only which instance/provider/mode
3799            // now carries a harness-MCP door.
3800            tracing::info!(
3801                node_id = %self.node_id,
3802                instance_id = ?instance_id,
3803                provider = %provider,
3804                mode = ?mode,
3805                "harness MCP overlay installed for spawn",
3806            );
3807            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3808        } else {
3809            None
3810        };
3811        let prepared_materialization = self.prepare_session_materialization(
3812            &address,
3813            &provider,
3814            mode,
3815            environment_profile,
3816            bundle,
3817            context,
3818            managed_authority.cloned(),
3819        )?;
3820        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3821            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3822            None => (None, None),
3823        };
3824        let materialization_id = materialization_guard
3825            .as_ref()
3826            .and_then(SessionMaterializationGuard::id)
3827            .cloned();
3828        let mut environment_selection = SpawnPreparer::select_environment(self, 
3829            instance_id,
3830            &provider,
3831            mode,
3832            environment_profile,
3833        )?;
3834        // Station network overlays: Codex `-c network_access` and/or Claude
3835        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3836        let provider_home = materialization_guard
3837            .as_ref()
3838            .and_then(SessionMaterializationGuard::provider_home);
3839        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3840            &provider,
3841            mode,
3842            approval_level,
3843            network_allowlist,
3844            provider_home,
3845        )?;
3846        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3847            (prepared, args) if args.is_empty() => prepared,
3848            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3849                overlay.append_extra_args(args).map_err(|_| {
3850                    failure(
3851                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3852                        "network allowlist overlay could not merge with instance launch args",
3853                    )
3854                })?;
3855                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3856            }
3857            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3858                Some(PreparedNativeLaunchOverlay::Instance(
3859                    overlay
3860                        .into_instance_with_extra_args(args)
3861                        .map_err(|_| {
3862                            failure(
3863                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3864                                "network allowlist overlay could not merge with environment overlay",
3865                            )
3866                        })?,
3867                ))
3868            }
3869            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3870                NativeInstanceLaunchOverlay::new(
3871                    provider.clone(),
3872                    match mode {
3873                        SessionMode::Pty => TransportKind::Pty,
3874                        SessionMode::Acp => TransportKind::Acp,
3875                        SessionMode::Inline => TransportKind::Pipe,
3876                    },
3877                    Vec::new(),
3878                    args,
3879                )
3880                .map_err(|_| {
3881                    failure(
3882                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3883                        "network allowlist overlay is invalid for this transport",
3884                    )
3885                })?,
3886            )),
3887        };
3888        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3889        // overlay or appended to the overlay the spawn already carries.
3890        #[cfg(unix)]
3891        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
3892        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3893            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3894        });
3895        #[cfg(unix)]
3896        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3897            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3898                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3899                let uid = unsafe { libc_geteuid() };
3900                let handle = proxy.reserve(&session_key, m4a_provider, uid);
3901                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3902                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3903                Some(vec![
3904                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3905                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3906                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3907                ])
3908            }
3909            _ => None,
3910        };
3911        #[cfg(not(unix))]
3912        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3913        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3914            (SessionMode::Pty, _, _, Some(identity)) => {
3915                let merged = match environment_overlay {
3916                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3917                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3918                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3919                    }
3920                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3921                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3922                    }
3923                }
3924                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3925                Some(merged)
3926            }
3927            (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3928                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3929                match accounts.provision(m4a_provider, &session_key) {
3930                    Ok(account) => {
3931                        let identity = account.env_mutations();
3932                        let merged = match environment_overlay {
3933                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3934                                .map(PreparedNativeLaunchOverlay::Instance),
3935                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3936                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3937                            }
3938                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3939                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3940                            }
3941                        }
3942                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3943                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3944                        self.m4a_live
3945                            .lock()
3946                            .unwrap_or_else(|p| p.into_inner())
3947                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3948                        Some(merged)
3949                    }
3950                    Err(error) => {
3951                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3952                        environment_overlay
3953                    }
3954                }
3955            }
3956            (SessionMode::Pty, Some(_), None, _) => {
3957                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3958                environment_overlay
3959            }
3960            _ => environment_overlay,
3961        };
3962        let mut instance_overlay = environment_overlay
3963            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3964            .transpose()?
3965            .flatten();
3966        Ok(PreparedSpawn {
3967            instance_overlay,
3968            environment_selection,
3969            materialization_guard,
3970            harness_mcp_overlay,
3971            #[cfg(feature = "dig2-station-probe")]
3972            browser_station_lease_guard,
3973            materialization_id,
3974        })
3975    }
3976}
3977
3978struct SessionRecords {
3979    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3980}
3981
3982struct SpawnIdempotencyCache {
3983    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3984}
3985
3986struct SpawnIdempotencyEntry {
3987    value: SpawnIdempotencyValue,
3988    expires_at: Instant,
3989}
3990
3991enum SpawnIdempotencyValue {
3992    Standard {
3993        spec: SpawnSpec,
3994        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3995    },
3996    Managed {
3997        request: ManagedWorktreeSpawnRequest,
3998        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3999    },
4000    HarnessMcp,
4001}
4002
4003enum DurableManagedSpawnReplayDecision {
4004    Reserved,
4005    PendingLinked,
4006    Committed(ManagedWorktreeSpawnReceipt),
4007}
4008
4009impl SpawnIdempotencyValue {
4010    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4011        match self {
4012            Self::Standard { result, .. } => result
4013                .as_ref()
4014                .ok()
4015                .and_then(|receipt| receipt.context.as_ref()),
4016            Self::Managed { result, .. } => result
4017                .as_ref()
4018                .ok()
4019                .and_then(|receipt| receipt.spawn.context.as_ref()),
4020            Self::HarnessMcp => None,
4021        }
4022        .is_some_and(|receipt| &receipt.id == context_id)
4023    }
4024}
4025
4026#[derive(Clone, Copy)]
4027struct ControllerLease {
4028    connection_id: u64,
4029    expires_at: Instant,
4030}
4031
4032struct NodeEventHistory {
4033    last_sequence: u64,
4034    replay_floor_sequence: u64,
4035    events: VecDeque<NodeEventEnvelope>,
4036    record_providers: BTreeMap<SessionRecordId, AgentId>,
4037    removed_record_providers: BTreeMap<u64, AgentId>,
4038}
4039
4040impl NodeEventHistory {
4041    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4042        Self {
4043            last_sequence: 0,
4044            replay_floor_sequence: 1,
4045            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4046            record_providers,
4047            removed_record_providers: BTreeMap::new(),
4048        }
4049    }
4050}
4051
4052impl NodeShared {
4053    fn new_with_incarnation(
4054        handle: Gate4AgentHandle,
4055        access_token: String,
4056        node_id: NodeId,
4057        incarnation_id: NodeIncarnationId,
4058        workspaces: Vec<WorkspaceConfig>,
4059        enabled_providers: Vec<AgentId>,
4060        provider_runtime_statuses: ProviderRuntimeStatuses,
4061        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4062        provider_contracts: Vec<ProviderContractSupport>,
4063        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4064        spawn_profiles: SpawnProfileRegistry,
4065        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4066        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4067        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4068        state_path: Option<PathBuf>,
4069        records: Vec<ManagedSessionRecord>,
4070        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4071        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4072        materializations: Vec<MaterializationOwnershipRecord>,
4073        persistence_warning: Option<String>,
4074        input_settle_timeout_ms: u64,
4075    ) -> Self {
4076        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4077        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4078        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4079        let (harness_mcp_proxy_event_tx, _) =
4080            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4081        let record_providers = records
4082            .iter()
4083            .map(|record| (record.record_id.clone(), record.provider.clone()))
4084            .collect();
4085        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4086            .iter()
4087            .map(|workspace| {
4088                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4089            })
4090            .collect();
4091        for lease in &managed_worktrees {
4092            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4093        }
4094        let managed_worktree_profiles = workspaces.iter()
4095            .map(|workspace| (
4096                workspace.workspace_id.clone(),
4097                workspace.managed_worktree_profiles.clone(),
4098            ))
4099            .collect();
4100        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4101            managed_worktrees,
4102            managed_worktree_tombstones,
4103        ).expect("durable managed worktree registry was validated while loading");
4104        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4105        managed_worktrees.reattach_record_holders(
4106            &managed_worktree_record_holders(&records),
4107            unix_time_ms(),
4108        );
4109        Self {
4110            handle,
4111            access_token,
4112            node_id,
4113            incarnation_id,
4114            started_at_unix_ms: std::time::SystemTime::now()
4115                .duration_since(std::time::UNIX_EPOCH)
4116                .unwrap_or_default()
4117                .as_millis()
4118                .min(u64::MAX as u128) as u64,
4119            workspaces: RwLock::new(
4120                workspaces
4121                    .into_iter()
4122                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4123                    .collect(),
4124            ),
4125            worktree_service_modes,
4126            managed_worktree_profiles,
4127            managed_worktrees: Mutex::new(managed_worktrees),
4128            enabled_providers,
4129            provider_runtime_statuses,
4130            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4131            provider_runtime_monitor,
4132            provider_contracts,
4133            provider_adapter_contracts,
4134            spawn_profiles,
4135            environment_profiles: RwLock::new(BTreeMap::new()),
4136            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4137            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4138            #[cfg(feature = "dig2-station-probe")]
4139            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4140            bundle_catalog: RwLock::new(BundleCatalog::default()),
4141            delivery_store: Mutex::new(None),
4142            context_pack_store: Mutex::new(None),
4143            context_catalog: RwLock::new(ContextPackCatalog::default()),
4144            native_launch_profile_control,
4145            harness_mcp_registry: None,
4146            native_session_catalog,
4147            session_environment_materializer,
4148            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4149            m4a_live: Mutex::new(BTreeMap::new()),
4150            #[cfg(unix)]
4151            signer_proxy: std::sync::OnceLock::new(),
4152            materializations: Mutex::new(
4153                materializations
4154                    .into_iter()
4155                    .map(|record| (record.id().clone(), record))
4156                    .collect(),
4157            ),
4158            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4159                entries: BTreeMap::new(),
4160            }),
4161            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4162            controller: Mutex::new(None),
4163            provisional_spawns: Mutex::new(BTreeMap::new()),
4164            pending_acp_options: Mutex::new(BTreeMap::new()),
4165            history: Mutex::new(NodeEventHistory::new(record_providers)),
4166            event_tx,
4167            terminal_event_tx,
4168            agent_stream_event_tx,
4169            harness_mcp_proxy_event_tx,
4170            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4171            pty_input_wake: tokio::sync::Notify::new(),
4172            pty_input_marks: Mutex::new(BTreeMap::new()),
4173            next_connection_id: AtomicU64::new(1),
4174            next_instance_id: AtomicU64::new(1),
4175            next_command_id: AtomicU64::new(1),
4176            shutdown: AtomicBool::new(false),
4177            shutdown_notify: Notify::new(),
4178            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4179            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4180            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4181            mutation_gate: AsyncMutex::new(()),
4182            session_bindings: Mutex::new(BTreeMap::new()),
4183            session_records: Mutex::new(SessionRecords {
4184                records: records
4185                    .into_iter()
4186                    .map(|record| (record.record_id.clone(), record))
4187                    .collect(),
4188            }),
4189            state_path,
4190            persistence_error: RwLock::new(
4191                persistence_warning
4192                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4193            ),
4194            state_transaction: Mutex::new(()),
4195            input_settle_timeout_ms,
4196            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4197            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4198            shell_efficiency_profile: OnceLock::new(),
4199            native_session_gauge: AtomicUsize::new(0),
4200            connection_loop_iterations: AtomicU64::new(0),
4201            connection_events_sent: AtomicU64::new(0),
4202            rejected_commands_total: AtomicU64::new(0),
4203            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4204            drive_loop_iterations_total: AtomicU64::new(0),
4205            drive_loop_iterations_idle: AtomicU64::new(0),
4206            session_record_retention: SessionRecordRetentionConfig::default(),
4207            retired_records_total: AtomicU64::new(0),
4208            #[cfg(feature = "fixture")]
4209            fixture_semantic_hook_policy: false,
4210            #[cfg(feature = "fixture")]
4211            fixture_spawn_managed_worktree_v2_failure_probe:
4212                SpawnManagedWorktreeV2FailureProbe::default(),
4213        }
4214    }
4215
4216    #[cfg(test)]
4217    fn new(
4218        handle: Gate4AgentHandle,
4219        access_token: String,
4220        node_id: NodeId,
4221        workspaces: Vec<WorkspaceConfig>,
4222        enabled_providers: Vec<AgentId>,
4223    ) -> Self {
4224        Self::new_with_incarnation(
4225            handle,
4226            access_token,
4227            node_id,
4228            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4229            workspaces,
4230            enabled_providers,
4231            ProviderRuntimeStatuses::default(),
4232            None,
4233            Vec::new(),
4234            Vec::new(),
4235            SpawnProfileRegistry::default(),
4236            None,
4237            None,
4238            None,
4239            None,
4240            Vec::new(),
4241            Vec::new(),
4242            Vec::new(),
4243            Vec::new(),
4244            None,
4245            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4246        )
4247    }
4248
4249    fn persistence_error(&self) -> Option<String> {
4250        self.persistence_error
4251            .read()
4252            .unwrap_or_else(|poisoned| poisoned.into_inner())
4253            .clone()
4254    }
4255
4256    async fn admit_provider_runtime(
4257        &self,
4258        provider: &AgentId,
4259        requirement: ProviderRuntimeRequirement,
4260    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4261        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4262            let pipe_transport = monitor.supports_pipe_transport(provider);
4263            let acp_transport = monitor.supports_acp_transport(provider);
4264            let provider = provider.clone();
4265            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4266            let (status, admission) = timeout(
4267                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4268                refresh,
4269            )
4270            .await
4271            .map_err(|_| failure(
4272                NodeFailureCode::BackendBusy,
4273                "provider runtime probe exceeded its bounded deadline",
4274            ))?
4275            .map_err(|_| failure(
4276                NodeFailureCode::BackendOperationFailed,
4277                "provider runtime probe task failed",
4278            ))?;
4279            if let Some(status) = status {
4280                self
4281                    .provider_runtime_status_updates
4282                    .write()
4283                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4284                    .insert(status.provider().clone(), status);
4285            }
4286            admission.and_then(|policy| {
4287                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4288            })
4289        } else {
4290            // No live monitor means no catalog to consult -- there is
4291            // nothing here to confirm this provider declares a Pipe or ACP
4292            // transport, so Inline/Acp are rejected the same conservative
4293            // way an undeclared provider always was, rather than admitted
4294            // on trust.
4295            crate::provider_runtime::admit_status(
4296                &self.provider_runtime_statuses,
4297                provider,
4298                false,
4299                false,
4300                requirement,
4301            )
4302        };
4303        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4304        admission.map_err(|error| match error {
4305            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4306                NodeFailureCode::BackendOperationFailed,
4307                "provider launcher is unavailable",
4308            ),
4309            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4310                NodeFailureCode::UnsupportedCapability,
4311                "provider semantic capability is not verified",
4312            ),
4313            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4314                NodeFailureCode::BackendBusy,
4315                "provider runtime probe is already in progress",
4316            ),
4317        })
4318    }
4319
4320    /// Whether the catalog declares a Pipe transport for `provider` --
4321    /// the fact `require_policy` needs to admit or reject
4322    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4323    /// catalog to consult, so this stays conservative and rejects, matching
4324    /// `admit_provider_runtime`'s own no-monitor branch.
4325    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4326        match self.provider_runtime_monitor.as_deref() {
4327            Some(monitor) => monitor.supports_pipe_transport(provider),
4328            None => false,
4329        }
4330    }
4331
4332    /// Whether the catalog declares an ACP transport for `provider` -- the
4333    /// fact `require_policy` needs to admit or reject
4334    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4335    /// `provider_supports_pipe_transport` exactly, including the
4336    /// conservative no-monitor default.
4337    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4338        match self.provider_runtime_monitor.as_deref() {
4339            Some(monitor) => monitor.supports_acp_transport(provider),
4340            None => false,
4341        }
4342    }
4343
4344    /// Grants hook ingestion to a provider whose catalog entry declares a
4345    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4346    ///
4347    /// `admit_status`'s `RawPassthrough` arm hands back
4348    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4349    /// correct for everything it was written to answer, and wrong for hooks
4350    /// specifically. A hook event is not inferred from terminal behaviour,
4351    /// so it has nothing to do with whether a launcher probe verified this
4352    /// CLI version's PTY contract; the authority is the catalog declaring
4353    /// the adapter plus the ingress authenticating the route. Without this
4354    /// the node installs a provider's hooks, the CLI faithfully calls them,
4355    /// and every event is refused -- which is exactly the state grok was in.
4356    ///
4357    /// Applied to both admission paths so the monitor's own derivation and
4358    /// this coarse fallback cannot disagree about the same provider.
4359    fn admit_declared_hook_semantics(
4360        &self,
4361        provider: &AgentId,
4362        policy: ProviderRuntimePolicy,
4363    ) -> ProviderRuntimePolicy {
4364        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4365            return policy;
4366        }
4367        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4368            contract.provider == *provider && contract.family == AdapterFamily::Hook
4369        });
4370        if !declares_hook {
4371            return policy;
4372        }
4373        ProviderRuntimePolicy::new(
4374            policy.raw_pty_lifecycle,
4375            policy.semantic_readiness,
4376            policy.structured_prompt,
4377            policy.provider_session_identity,
4378            policy.semantic_resume,
4379            true,
4380        )
4381        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4382    }
4383
4384    /// Station network allowlist → provider launch overlay argv.
4385    ///
4386    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4387    ///   `provider_native` under Moderate (PTY or ACP).
4388    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4389    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4390    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4391    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4392    ///   no overlay.
4393    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4394    ///   empty overlay when no `provider_native`; invented native keys already
4395    ///   refuse at load / resolve.
4396    ///
4397    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4398    fn resolve_network_allowlist_overlay_args(
4399        &self,
4400        provider: &AgentId,
4401        mode: SessionMode,
4402        approval_level: ApprovalLevel,
4403        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4404        provider_home: Option<&Path>,
4405    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4406        let Some(allowlist_id) = network_allowlist else {
4407            return Ok(Vec::new());
4408        };
4409        let catalog = self
4410            .network_allowlist_catalog
4411            .read()
4412            .unwrap_or_else(|poisoned| poisoned.into_inner());
4413        let Some(entry) = catalog.get(allowlist_id) else {
4414            return Err(failure(
4415                NodeFailureCode::UnknownNetworkAllowlist,
4416                "spawn network allowlist is unavailable on this node",
4417            ));
4418        };
4419
4420        // Claude: settings-shaped overlay from permits (not provider_native).
4421        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4422            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4423                |message| {
4424                    failure(
4425                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4426                        &message,
4427                    )
4428                },
4429            )?;
4430            if !matches!(mode, SessionMode::Pty) {
4431                return Err(failure(
4432                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4433                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4434                ));
4435            }
4436            let Some(home) = provider_home else {
4437                return Err(failure(
4438                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4439                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4440                ));
4441            };
4442            let domains =
4443                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4444            if domains.is_empty() {
4445                return Err(failure(
4446                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4447                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4448                ));
4449            }
4450            let settings_path = home.join(
4451                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4452            );
4453            let json =
4454                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4455            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4456                |_| {
4457                    failure(
4458                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4459                        "Claude station network settings sidecar could not be written under ProviderHome",
4460                    )
4461                },
4462            )?;
4463            let overlay =
4464                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4465            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4466        }
4467
4468        let Some(native) = entry.provider_native.as_ref() else {
4469            return Ok(Vec::new());
4470        };
4471        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4472            provider.as_str(),
4473            approval_level,
4474            native,
4475        )
4476        .map_err(|message| {
4477            failure(
4478                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4479                &message,
4480            )
4481        })?;
4482        if overlay.is_empty() {
4483            return Ok(Vec::new());
4484        }
4485        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4486            return Err(failure(
4487                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4488                "Codex network_access -c overlay requires PTY or ACP transport",
4489            ));
4490        }
4491        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4492    }
4493
4494    /// Backward-compatible alias used by Codex-focused unit tests.
4495    fn resolve_codex_network_access_overlay_args(
4496        &self,
4497        provider: &AgentId,
4498        mode: SessionMode,
4499        approval_level: ApprovalLevel,
4500        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4501    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4502        self.resolve_network_allowlist_overlay_args(
4503            provider,
4504            mode,
4505            approval_level,
4506            network_allowlist,
4507            None,
4508        )
4509    }
4510
4511    fn resolve_environment_profile(
4512        &self,
4513        resolved: &ResolvedSpawnSpec,
4514    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4515        // Station network allowlist catalog enforce (plan
4516        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4517        // catalog — unknown ids refuse rather than silent ambient. Runs even
4518        // when no environment_profile_id is present so a bare allowlist still
4519        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4520        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4521            let catalog = self
4522                .network_allowlist_catalog
4523                .read()
4524                .unwrap_or_else(|poisoned| poisoned.into_inner());
4525            let Some(entry) = catalog.get(allowlist_id) else {
4526                return Err(failure(
4527                    NodeFailureCode::UnknownNetworkAllowlist,
4528                    "spawn network allowlist is unavailable on this node",
4529                ));
4530            };
4531            // Track B: refuse provider-native mappings this provider cannot
4532            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4533            if let Some(native) = entry.provider_native.as_ref() {
4534                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4535                    resolved.provider.as_str(),
4536                    resolved.approval_level,
4537                    native,
4538                ) {
4539                    return Err(failure(
4540                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4541                        &message,
4542                    ));
4543                }
4544            }
4545        }
4546        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4547        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4548        // cheap named-pipe path reachability must succeed (Windows
4549        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4550        // NodeFailureCode. Runs even without environment_profile_id (same
4551        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4552        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4553        // acquired later in spawn_session_with_deadline (after instance id).
4554        #[cfg(feature = "dig2-station-probe")]
4555        if resolved.browser_profile_id.is_some() {
4556            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4557            match probe_station_reachable() {
4558                Ok(()) => {}
4559                Err(StationProbeError::UnsupportedPlatform)
4560                | Err(StationProbeError::InvalidPipeSuffix) => {
4561                    return Err(failure(
4562                        NodeFailureCode::BrowserStationProbeUnavailable,
4563                        "dig2browser station probe unavailable on this platform",
4564                    ));
4565                }
4566                Err(StationProbeError::Unreachable) => {
4567                    return Err(failure(
4568                        NodeFailureCode::BrowserStationUnreachable,
4569                        "dig2browser station named-pipe is unreachable",
4570                    ));
4571                }
4572            }
4573        }
4574        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4575            return Ok(None);
4576        };
4577        let profiles = self
4578            .environment_profiles
4579            .read()
4580            .unwrap_or_else(|poisoned| poisoned.into_inner());
4581        let binding = profiles.get(profile_id).ok_or_else(|| {
4582            failure(
4583                NodeFailureCode::UnknownEnvironmentProfile,
4584                "spawn environment profile is unavailable on this node",
4585            )
4586        })?;
4587        if binding.provider != resolved.provider
4588            || binding.native_profile_id(resolved.mode).is_none()
4589        {
4590            return Err(failure(
4591                NodeFailureCode::EnvironmentProfileBindingMismatch,
4592                "spawn environment profile does not match the provider and mode",
4593            ));
4594        }
4595        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4596        // after catalog + optional dig2 probe above. Empty/whitespace ids
4597        // already refuse at Spawn*Id construction.
4598        Ok(Some(ResolvedEnvironmentProfileReceipt {
4599            profile_id: binding.id.clone(),
4600            profile_revision: binding.revision.clone(),
4601            network_allowlist: resolved.network_allowlist.clone(),
4602            browser_profile_id: resolved.browser_profile_id.clone(),
4603        }))
4604    }
4605
4606    fn resolve_bundle(
4607        &self,
4608        resolved: &ResolvedSpawnSpec,
4609        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4610    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4611        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4612            return Ok(None);
4613        };
4614        let catalog = self.bundle_catalog
4615            .read()
4616            .unwrap_or_else(|poisoned| poisoned.into_inner());
4617        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4618                failure(
4619                    NodeFailureCode::UnknownBundle,
4620                    "spawn bundle is unavailable on this node",
4621                )
4622            })?;
4623        self.bundle_layout(
4624            &resolved.provider,
4625            resolved.mode,
4626            environment_profile,
4627            bundle,
4628        )?;
4629        Ok(Some(bundle.receipt()))
4630    }
4631
4632    fn begin_delivery_stage(
4633        &self,
4634        manifest: DeliveryBundleManifestV2,
4635    ) -> Result<NodeResponse, NodeFailure> {
4636        let mut store = self
4637            .delivery_store
4638            .lock()
4639            .unwrap_or_else(|poisoned| poisoned.into_inner());
4640        let store = store.as_mut().ok_or_else(|| {
4641            failure(
4642                NodeFailureCode::DeliveryStageStorageFailed,
4643                "node delivery store is unavailable",
4644            )
4645        })?;
4646        let begun = store.begin(manifest).map_err(delivery_failure)?;
4647        Ok(NodeResponse::DeliveryStageBegun {
4648            stage_id: begun.stage_id,
4649            manifest_digest: begun.manifest_digest,
4650            missing_blobs: begun.missing_blobs,
4651        })
4652    }
4653
4654    fn put_delivery_blob_chunk(
4655        &self,
4656        stage_id: DeliveryStageId,
4657        blob_digest: DeliveryBlobDigestV1,
4658        offset: u64,
4659        chunk_hex: DeliveryBlobChunkHexV1,
4660    ) -> Result<NodeResponse, NodeFailure> {
4661        let mut store = self
4662            .delivery_store
4663            .lock()
4664            .unwrap_or_else(|poisoned| poisoned.into_inner());
4665        let store = store.as_mut().ok_or_else(|| {
4666            failure(
4667                NodeFailureCode::DeliveryStageStorageFailed,
4668                "node delivery store is unavailable",
4669            )
4670        })?;
4671        let accepted = store
4672            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4673            .map_err(delivery_failure)?;
4674        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4675            stage_id: accepted.stage_id,
4676            blob_digest: accepted.blob_digest,
4677            next_offset: accepted.next_offset,
4678        })
4679    }
4680
4681    fn commit_delivery_stage(
4682        &self,
4683        stage_id: DeliveryStageId,
4684    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4685        let mut store = self
4686            .delivery_store
4687            .lock()
4688            .unwrap_or_else(|poisoned| poisoned.into_inner());
4689        let store = store.as_mut().ok_or_else(|| {
4690            failure(
4691                NodeFailureCode::DeliveryStageStorageFailed,
4692                "node delivery store is unavailable",
4693            )
4694        })?;
4695        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4696        let mut catalog = self
4697            .bundle_catalog
4698            .write()
4699            .unwrap_or_else(|poisoned| poisoned.into_inner());
4700        let mut next_catalog = catalog.clone();
4701        next_catalog
4702            .insert_idempotent(prepared.bundle().clone())
4703            .map_err(|_| {
4704                failure(
4705                    NodeFailureCode::DeliveryStageConflict,
4706                    "delivery bundle identity conflicts with installed content",
4707                )
4708            })?;
4709        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4710        *catalog = next_catalog;
4711        Ok(receipt)
4712    }
4713
4714    fn abort_delivery_stage(
4715        &self,
4716        stage_id: &DeliveryStageId,
4717    ) -> Result<(), NodeFailure> {
4718        let mut store = self
4719            .delivery_store
4720            .lock()
4721            .unwrap_or_else(|poisoned| poisoned.into_inner());
4722        let store = store.as_mut().ok_or_else(|| {
4723            failure(
4724                NodeFailureCode::DeliveryStageStorageFailed,
4725                "node delivery store is unavailable",
4726            )
4727        })?;
4728        store.abort(stage_id).map_err(delivery_failure)
4729    }
4730
4731    fn resolve_context(
4732        &self,
4733        resolved: &ResolvedSpawnSpec,
4734    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4735        let Some(context_id) = resolved.context_id.as_ref() else {
4736            return Ok(None);
4737        };
4738        self.context_catalog
4739            .read()
4740            .unwrap_or_else(|poisoned| poisoned.into_inner())
4741            .get(context_id)
4742            .map(|context| context.receipt().clone())
4743            .ok_or_else(|| {
4744                failure(
4745                    NodeFailureCode::UnknownContextPack,
4746                    "spawn context pack is unavailable on this node",
4747                )
4748            })
4749            .map(Some)
4750    }
4751
4752    fn bundle_layout(
4753        &self,
4754        provider: &AgentId,
4755        mode: SessionMode,
4756        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4757        bundle: &NodeBundle,
4758    ) -> Result<BundleProviderLayout, NodeFailure> {
4759        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4760            failure(
4761                NodeFailureCode::BundleBindingMismatch,
4762                "spawn bundle does not match the provider and mode",
4763            )
4764        })?;
4765        if layout != BundleProviderLayout::Codex {
4766            return Ok(layout);
4767        }
4768        let profile = environment_profile.and_then(|receipt| {
4769            let profiles = self
4770                .environment_profiles
4771                .read()
4772                .unwrap_or_else(|poisoned| poisoned.into_inner());
4773            let binding = profiles.get(&receipt.profile_id)?;
4774            if binding.revision != receipt.profile_revision
4775                || &binding.provider != provider
4776                || binding.native_profile_id(mode).is_none()
4777            {
4778                return None;
4779            }
4780            self.environment_materialization_profiles
4781                .read()
4782                .unwrap_or_else(|poisoned| poisoned.into_inner())
4783                .get(&receipt.profile_id)
4784                .cloned()
4785        });
4786        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4787            return Err(failure(
4788                NodeFailureCode::BundleBindingMismatch,
4789                "Codex bundle requires an exact isolated CODEX_HOME profile",
4790            ));
4791        }
4792        Ok(layout)
4793    }
4794
4795    fn select_environment_profile(
4796        &self,
4797        instance_id: AgentInstanceId,
4798        provider: &AgentId,
4799        mode: SessionMode,
4800        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4801    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4802        let Some(environment_profile) = environment_profile else {
4803            return Ok(None);
4804        };
4805        let native_profile_id = {
4806            let profiles = self
4807                .environment_profiles
4808                .read()
4809                .unwrap_or_else(|poisoned| poisoned.into_inner());
4810            let binding = profiles
4811                .get(&environment_profile.profile_id)
4812                .ok_or_else(|| {
4813                    failure(
4814                        NodeFailureCode::UnknownEnvironmentProfile,
4815                        "session environment profile is unavailable on this node",
4816                    )
4817                })?;
4818            if &binding.revision != &environment_profile.profile_revision
4819                || &binding.provider != provider
4820            {
4821                return Err(failure(
4822                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4823                    "session environment profile revision or provider changed",
4824                ));
4825            }
4826            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4827                failure(
4828                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4829                    "session environment profile does not support this mode",
4830                )
4831            })?
4832        };
4833        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4834            failure(
4835                NodeFailureCode::BackendOperationFailed,
4836                "native environment profile controller is unavailable",
4837            )
4838        })?;
4839        control
4840            .select_native_launch_profile(instance_id, native_profile_id)
4841            .map_err(|_| {
4842                failure(
4843                    NodeFailureCode::BackendOperationFailed,
4844                    "native environment profile selection failed",
4845                )
4846            })?;
4847        Ok(Some(NativeEnvironmentSelectionGuard {
4848            control: Some(control),
4849            instance_id,
4850        }))
4851    }
4852
4853    fn install_prepared_launch_overlay(
4854        &self,
4855        instance_id: AgentInstanceId,
4856        overlay: PreparedNativeLaunchOverlay,
4857    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4858        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4859            failure(
4860                NodeFailureCode::BackendOperationFailed,
4861                "native launch profile controller is unavailable",
4862            )
4863        })?;
4864        match overlay {
4865            PreparedNativeLaunchOverlay::Environment(overlay) => {
4866                control
4867                    .install_native_launch_environment_overlay(instance_id, overlay)
4868                    .map_err(|_| failure(
4869                        NodeFailureCode::BackendOperationFailed,
4870                        "session environment overlay installation failed",
4871                    ))?;
4872                Ok(None)
4873            }
4874            PreparedNativeLaunchOverlay::Instance(overlay) => {
4875                control
4876                    .install_native_instance_launch_overlay(instance_id, overlay)
4877                    .map_err(|_| failure(
4878                        NodeFailureCode::BundleBindingMismatch,
4879                        "session bundle launch overlay installation failed",
4880                    ))?;
4881                Ok(Some(NativeInstanceOverlayGuard {
4882                    control: Some(control),
4883                    instance_id,
4884                }))
4885            }
4886        }
4887    }
4888
4889    fn environment_materialization_profile(
4890        &self,
4891        environment_profile: &ResolvedEnvironmentProfileReceipt,
4892    ) -> Option<NodeSessionMaterializationProfile> {
4893        self.environment_materialization_profiles
4894            .read()
4895            .unwrap_or_else(|poisoned| poisoned.into_inner())
4896            .get(&environment_profile.profile_id)
4897            .cloned()
4898    }
4899
4900    fn materialization_profile(
4901        &self,
4902        provider: &AgentId,
4903        mode: SessionMode,
4904        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4905        bundle: Option<&ResolvedBundleReceipt>,
4906        context: Option<&ResolvedContextPackReceipt>,
4907    ) -> Result<Option<(
4908        NodeSessionMaterializationProfile,
4909        bool,
4910        Option<BundleProviderLayout>,
4911    )>, NodeFailure> {
4912        let environment = environment_profile
4913            .and_then(|receipt| self.environment_materialization_profile(receipt));
4914        let (bundle, bundle_layout) = match bundle {
4915            Some(receipt) => {
4916                let catalog = self.bundle_catalog.read()
4917                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4918                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4919                    NodeFailureCode::UnknownBundle,
4920                    "session bundle is unavailable on this node",
4921                ))?;
4922                if installed.receipt() != *receipt {
4923                    return Err(failure(
4924                        NodeFailureCode::BundleBindingMismatch,
4925                        "session bundle revision or digest changed",
4926                    ));
4927                }
4928                let layout = self.bundle_layout(
4929                    provider,
4930                    mode,
4931                    environment_profile,
4932                    installed,
4933                )?;
4934                (Some(installed.clone()), Some(layout))
4935            }
4936            None => (None, None),
4937        };
4938        let has_environment_overlay = environment.is_some() || context.is_some();
4939        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4940            (None, None, None) => None,
4941            (Some(profile), None, None) => Some(Ok(profile)),
4942            (None, Some(bundle), Some(layout)) => {
4943                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4944            }
4945            (Some(profile), Some(bundle), Some(layout)) => {
4946                Some(profile.with_bundle(bundle, layout))
4947            }
4948            _ => {
4949                return Err(failure(
4950                    NodeFailureCode::BundleBindingMismatch,
4951                    "session bundle provider layout is unavailable",
4952                ));
4953            }
4954        };
4955        let profile = profile
4956            .transpose()
4957            .map_err(|_| failure(
4958                NodeFailureCode::BundleMaterializationFailed,
4959                "session bundle materialization profile is invalid",
4960            ))?;
4961        let profile = match (profile, context) {
4962            (Some(profile), Some(receipt)) => {
4963                let catalog = self.context_catalog
4964                    .read()
4965                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4966                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4967                    NodeFailureCode::UnknownContextPack,
4968                    "session context pack is unavailable on this node",
4969                ))?;
4970                if pack.receipt() != receipt {
4971                    return Err(failure(
4972                        NodeFailureCode::ContextPackMaterializationFailed,
4973                        "session context pack identity changed",
4974                    ));
4975                }
4976                profile.with_context(pack)
4977            }
4978            (None, Some(receipt)) => {
4979                let catalog = self.context_catalog
4980                    .read()
4981                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4982                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4983                    NodeFailureCode::UnknownContextPack,
4984                    "session context pack is unavailable on this node",
4985                ))?;
4986                if pack.receipt() != receipt {
4987                    return Err(failure(
4988                        NodeFailureCode::ContextPackMaterializationFailed,
4989                        "session context pack identity changed",
4990                    ));
4991                }
4992                NodeSessionMaterializationProfile::from_context(pack)
4993            }
4994            (Some(profile), None) => Ok(profile),
4995            (None, None) => return Ok(None),
4996        }
4997        .map_err(|_| failure(
4998            NodeFailureCode::ContextPackMaterializationFailed,
4999            "session context pack materialization profile is invalid",
5000        ))?;
5001        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5002    }
5003
5004    fn prepare_session_materialization<'a>(
5005        &'a self,
5006        address: &SessionAddress,
5007        provider: &AgentId,
5008        mode: SessionMode,
5009        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5010        bundle: Option<&ResolvedBundleReceipt>,
5011        context: Option<&ResolvedContextPackReceipt>,
5012        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5013    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5014        let Some((profile, has_environment_overlay, bundle_layout)) =
5015            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5016        else {
5017            return Ok(None);
5018        };
5019        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5020            failure(
5021                NodeFailureCode::BackendOperationFailed,
5022                "session environment materializer is unavailable",
5023            )
5024        })?;
5025        let materialization_failure_code = if context.is_some() {
5026            NodeFailureCode::ContextPackMaterializationFailed
5027        } else if bundle.is_some() {
5028            NodeFailureCode::BundleMaterializationFailed
5029        } else {
5030            NodeFailureCode::BackendOperationFailed
5031        };
5032        let id = MaterializationId::new(format!(
5033            "mat-{}-{}-{}",
5034            self.incarnation_id,
5035            address.session.instance_id.0,
5036            address.session.generation.0,
5037        ))
5038        .map_err(|_| {
5039            failure(
5040                NodeFailureCode::BackendOperationFailed,
5041                "session environment materialization identity failed",
5042            )
5043        })?;
5044        let owner = MaterializationOwner::Session {
5045            incarnation_id: self.incarnation_id,
5046            instance_id: address.session.instance_id,
5047            generation: address.session.generation,
5048        };
5049        let mut ownership = materializer
5050            .begin(
5051                id.clone(),
5052                environment_profile.cloned(),
5053                bundle.cloned(),
5054                context.cloned(),
5055                owner,
5056                managed_lease_id,
5057                &profile,
5058                unix_time_ms(),
5059            )
5060            .map_err(|_| {
5061                failure(
5062                    materialization_failure_code,
5063                    "session materialization preparation failed",
5064                )
5065            })?;
5066        {
5067            let _transaction = self
5068                .state_transaction
5069                .lock()
5070                .unwrap_or_else(|poisoned| poisoned.into_inner());
5071            let duplicate = self
5072                .materializations
5073                .lock()
5074                .unwrap_or_else(|poisoned| poisoned.into_inner())
5075                .insert(id.clone(), ownership.clone());
5076            if duplicate.is_some() {
5077                return Err(failure(
5078                    NodeFailureCode::BackendOperationFailed,
5079                    "session environment materialization identity conflict",
5080                ));
5081            }
5082            if let Err(error) = self.persist_state_locked() {
5083                self.materializations
5084                    .lock()
5085                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5086                    .remove(&id);
5087                return Err(persistence_failure(error));
5088            }
5089        }
5090        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5091            Ok(environment) => environment,
5092            Err(_) => {
5093                let transaction = self
5094                    .state_transaction
5095                    .lock()
5096                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5097                self.materializations
5098                    .lock()
5099                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5100                    .insert(id.clone(), ownership.clone());
5101                if self.persist_state_locked().is_err() {
5102                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5103                }
5104                drop(transaction);
5105                if ownership.state() == MaterializationState::CleanupRequired {
5106                    let _ = self.cleanup_materialization(&id);
5107                }
5108                return Err(failure(
5109                    materialization_failure_code,
5110                    "session materialization failed",
5111                ));
5112            }
5113        };
5114        {
5115            let _transaction = self
5116                .state_transaction
5117                .lock()
5118                .unwrap_or_else(|poisoned| poisoned.into_inner());
5119            self.materializations
5120                .lock()
5121                .unwrap_or_else(|poisoned| poisoned.into_inner())
5122                .insert(id.clone(), ownership.clone());
5123            if let Err(error) = self.persist_state_locked() {
5124                drop(_transaction);
5125                let _ = self.cleanup_materialization(&id);
5126                return Err(persistence_failure(error));
5127            }
5128        }
5129        let guard = SessionMaterializationGuard {
5130            shared: self,
5131            id: Some(id),
5132            provider_home: Some(ownership.provider_home().to_path_buf()),
5133        };
5134        let bundle_arguments = match bundle {
5135            Some(receipt) => {
5136                let catalog = self.bundle_catalog.read()
5137                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5138                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5139                    NodeFailureCode::UnknownBundle,
5140                    "session bundle is unavailable on this node",
5141                ))?;
5142                if installed.receipt() != *receipt {
5143                    return Err(failure(
5144                        NodeFailureCode::BundleBindingMismatch,
5145                        "session bundle revision or digest changed",
5146                    ));
5147                }
5148                let layout = bundle_layout.ok_or_else(|| failure(
5149                    NodeFailureCode::BundleBindingMismatch,
5150                    "materialized bundle provider layout is unavailable",
5151                ))?;
5152                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5153                    failure(
5154                        NodeFailureCode::BundleMaterializationFailed,
5155                        "session bundle byte revalidation failed",
5156                    )
5157                })?;
5158                Some(bundle_launch_arguments(
5159                    layout,
5160                    ownership.bundle_root(),
5161                    ownership.provider_home(),
5162                ).map_err(|_| failure(
5163                    NodeFailureCode::BundleBindingMismatch,
5164                    "session bundle launch binding is invalid",
5165                ))?)
5166            }
5167            None => None,
5168        };
5169        if let Some(receipt) = context {
5170            materializer
5171                .revalidate_context(&ownership, receipt)
5172                .map_err(|_| failure(
5173                    NodeFailureCode::ContextPackMaterializationFailed,
5174                    "session context pack byte revalidation failed",
5175                ))?;
5176        }
5177        let transport = match mode {
5178            SessionMode::Pty => TransportKind::Pty,
5179            SessionMode::Inline => TransportKind::Pipe,
5180            SessionMode::Acp => TransportKind::Acp,
5181        };
5182        let overlay = if let Some(extra_args) = bundle_arguments {
5183            Some(PreparedNativeLaunchOverlay::Instance(
5184                NativeInstanceLaunchOverlay::new(
5185                    provider.clone(),
5186                    transport,
5187                    if has_environment_overlay {
5188                        environment
5189                    } else {
5190                        Vec::new()
5191                    },
5192                    extra_args,
5193                )
5194                .map_err(|_| {
5195                    failure(
5196                        NodeFailureCode::BundleBindingMismatch,
5197                        "session bundle launch overlay is invalid",
5198                    )
5199                })?,
5200            ))
5201        } else if has_environment_overlay {
5202            let context_only = environment_profile.is_none()
5203                && bundle.is_none()
5204                && context.is_some();
5205            let overlay = if context_only {
5206                NativeLaunchEnvironmentOverlay::new_context_root(
5207                    provider.clone(),
5208                    transport,
5209                    environment,
5210                )
5211                .map_err(|_| {
5212                    failure(
5213                        NodeFailureCode::ContextPackMaterializationFailed,
5214                        "context-only launch overlay is invalid",
5215                    )
5216                })?
5217            } else {
5218                NativeLaunchEnvironmentOverlay::new(
5219                    provider.clone(),
5220                    transport,
5221                    environment,
5222                )
5223                .map_err(|_| {
5224                    failure(
5225                        NodeFailureCode::BackendOperationFailed,
5226                        "session environment overlay is invalid",
5227                    )
5228                })?
5229            };
5230            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5231        } else {
5232            None
5233        };
5234        Ok(Some((overlay, guard)))
5235    }
5236
5237    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5238        let Some(mut ownership) = self
5239            .materializations
5240            .lock()
5241            .unwrap_or_else(|poisoned| poisoned.into_inner())
5242            .get(id)
5243            .cloned()
5244        else {
5245            return Ok(());
5246        };
5247        if ownership.state() != MaterializationState::CleanupRequired {
5248            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5249                failure(
5250                    NodeFailureCode::BackendOperationFailed,
5251                    "session environment cleanup state is invalid",
5252                )
5253            })?;
5254        }
5255        {
5256            let _transaction = self
5257                .state_transaction
5258                .lock()
5259                .unwrap_or_else(|poisoned| poisoned.into_inner());
5260            let previous = self
5261                .materializations
5262                .lock()
5263                .unwrap_or_else(|poisoned| poisoned.into_inner())
5264                .insert(id.clone(), ownership.clone());
5265            if let Err(error) = self.persist_state_locked() {
5266                let mut materializations = self
5267                    .materializations
5268                    .lock()
5269                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5270                if let Some(previous) = previous {
5271                    materializations.insert(id.clone(), previous);
5272                }
5273                return Err(persistence_failure(error));
5274            }
5275        }
5276        let cleanup = self
5277            .session_environment_materializer
5278            .as_ref()
5279            .ok_or(())
5280            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5281        if cleanup.is_err() {
5282            let _ = ownership.mark_recovery_required(unix_time_ms());
5283            self.materializations
5284                .lock()
5285                .unwrap_or_else(|poisoned| poisoned.into_inner())
5286                .insert(id.clone(), ownership);
5287            if self.persist_state().is_err() {
5288                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5289            }
5290            return Err(failure(
5291                NodeFailureCode::BackendOperationFailed,
5292                "session environment cleanup requires recovery",
5293            ));
5294        }
5295        let _transaction = self
5296            .state_transaction
5297            .lock()
5298            .unwrap_or_else(|poisoned| poisoned.into_inner());
5299        self.materializations
5300            .lock()
5301            .unwrap_or_else(|poisoned| poisoned.into_inner())
5302            .remove(id);
5303        if let Err(error) = self.persist_state_locked() {
5304            self.materializations
5305                .lock()
5306                .unwrap_or_else(|poisoned| poisoned.into_inner())
5307                .insert(id.clone(), ownership);
5308            return Err(persistence_failure(error));
5309        }
5310        Ok(())
5311    }
5312
5313    fn resolve_record_materialization(
5314        &self,
5315        record_id: &SessionRecordId,
5316        provider: &AgentId,
5317        mode: SessionMode,
5318        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5319        bundle: Option<&ResolvedBundleReceipt>,
5320        context: Option<&ResolvedContextPackReceipt>,
5321    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5322        let ownership = {
5323            let materializations = self
5324                .materializations
5325                .lock()
5326                .unwrap_or_else(|poisoned| poisoned.into_inner());
5327            let mut matches = materializations.values().filter(|record| {
5328                record.owner()
5329                    == &MaterializationOwner::Record {
5330                        record_id: record_id.clone(),
5331                    }
5332            });
5333            let ownership = matches.next().cloned();
5334            if matches.next().is_some() {
5335                return Err(failure(
5336                    NodeFailureCode::BackendOperationFailed,
5337                    "managed session has conflicting environment ownership",
5338                ));
5339            }
5340            ownership
5341        };
5342        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5343            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5344                failure(
5345                    NodeFailureCode::ContextPackMaterializationFailed,
5346                    "session context materializer is unavailable",
5347                )
5348            })?;
5349            let pack = materializer
5350                .revalidate_context(ownership, receipt)
5351                .map_err(|_| {
5352                    self.mark_materialization_recovery_required(ownership.id());
5353                    failure(
5354                        NodeFailureCode::ContextPackMaterializationFailed,
5355                        "managed session context pack revalidation failed",
5356                    )
5357                })?;
5358            self.context_catalog
5359                .write()
5360                .unwrap_or_else(|poisoned| poisoned.into_inner())
5361                .insert(pack)
5362                .map_err(|_| failure(
5363                    NodeFailureCode::ContextPackMaterializationFailed,
5364                    "managed session context pack catalog restore failed",
5365                ))?;
5366        }
5367        let materialization = self.materialization_profile(
5368            provider,
5369            mode,
5370            environment_profile,
5371            bundle,
5372            context,
5373        )?;
5374        match (ownership, materialization) {
5375            (None, None) => Ok(None),
5376            (None, Some(_)) | (Some(_), None) => Err(failure(
5377                NodeFailureCode::BackendOperationFailed,
5378                "managed session materialization is unavailable",
5379            )),
5380            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5381                if ownership.environment_profile() != environment_profile
5382                    || ownership.bundle() != bundle
5383                    || ownership.context() != context
5384                    || ownership.state() != MaterializationState::Ready
5385                {
5386                    self.mark_materialization_recovery_required(ownership.id());
5387                    return Err(failure(
5388                        NodeFailureCode::BackendOperationFailed,
5389                        "managed session environment ownership requires recovery",
5390                    ));
5391                }
5392                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5393                    failure(
5394                        NodeFailureCode::BackendOperationFailed,
5395                        "session environment materializer is unavailable",
5396                    )
5397                })?;
5398                if materializer.revalidate(&ownership).is_err() {
5399                    self.mark_materialization_recovery_required(ownership.id());
5400                    return Err(failure(
5401                        NodeFailureCode::BackendOperationFailed,
5402                        "managed session environment revalidation failed",
5403                    ));
5404                }
5405                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5406                    let catalog = self.bundle_catalog.read()
5407                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5408                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5409                        return Err(failure(
5410                            NodeFailureCode::UnknownBundle,
5411                            "managed session bundle is unavailable",
5412                        ));
5413                    };
5414                    let layout = bundle_layout.ok_or_else(|| failure(
5415                        NodeFailureCode::BundleBindingMismatch,
5416                        "record bundle provider layout is unavailable",
5417                    ))?;
5418                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5419                        drop(catalog);
5420                        self.mark_materialization_recovery_required(ownership.id());
5421                        return Err(failure(
5422                            NodeFailureCode::BundleMaterializationFailed,
5423                            "managed session bundle revalidation failed",
5424                        ));
5425                    }
5426                    Some(bundle_launch_arguments(
5427                        layout,
5428                        ownership.bundle_root(),
5429                        ownership.provider_home(),
5430                    ).map_err(|_| failure(
5431                        NodeFailureCode::BundleBindingMismatch,
5432                        "managed session bundle launch binding is invalid",
5433                    ))?)
5434                } else {
5435                    None
5436                };
5437                if !has_environment_materialization {
5438                    let overlay = bundle_arguments.map(|extra_args| {
5439                        NativeInstanceLaunchOverlay::new(
5440                            provider.clone(),
5441                            TransportKind::Pty,
5442                            Vec::new(),
5443                            extra_args,
5444                        )
5445                        .map(PreparedNativeLaunchOverlay::Instance)
5446                        .map_err(|_| failure(
5447                            NodeFailureCode::BundleBindingMismatch,
5448                            "managed session bundle launch overlay is invalid",
5449                        ))
5450                    }).transpose()?;
5451                    return Ok(Some((ownership.id().clone(), overlay)));
5452                }
5453                let environment = match materializer.resolve_environment(&ownership, &profile) {
5454                    Ok(environment) => environment,
5455                    Err(
5456                        SessionEnvironmentMaterializeError::SecretUnavailable
5457                        | SessionEnvironmentMaterializeError::SecretDenied
5458                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5459                    ) => {
5460                        return Err(failure(
5461                            NodeFailureCode::BackendOperationFailed,
5462                            "managed session environment resolution failed",
5463                        ));
5464                    }
5465                    Err(
5466                        SessionEnvironmentMaterializeError::InvalidRoot
5467                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5468                        | SessionEnvironmentMaterializeError::Filesystem(_)
5469                        | SessionEnvironmentMaterializeError::Record(_),
5470                    ) => {
5471                        self.mark_materialization_recovery_required(ownership.id());
5472                        return Err(failure(
5473                            NodeFailureCode::BackendOperationFailed,
5474                            "managed session environment resolution failed",
5475                        ));
5476                    }
5477                };
5478                let transport = match mode {
5479                    SessionMode::Pty => TransportKind::Pty,
5480                    SessionMode::Inline => TransportKind::Pipe,
5481                    SessionMode::Acp => TransportKind::Acp,
5482                };
5483                let overlay = match bundle_arguments {
5484                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5485                        NativeInstanceLaunchOverlay::new(
5486                            provider.clone(),
5487                            transport,
5488                            environment,
5489                            extra_args,
5490                        )
5491                        .map_err(|_| failure(
5492                            NodeFailureCode::BundleBindingMismatch,
5493                            "managed session bundle launch overlay is invalid",
5494                        ))?,
5495                    ),
5496                    None => PreparedNativeLaunchOverlay::Environment(
5497                        NativeLaunchEnvironmentOverlay::new(
5498                            provider.clone(),
5499                            transport,
5500                            environment,
5501                        )
5502                        .map_err(|_| {
5503                            failure(
5504                                NodeFailureCode::BackendOperationFailed,
5505                                "managed session environment overlay is invalid",
5506                            )
5507                        })?,
5508                    ),
5509                };
5510                Ok(Some((ownership.id().clone(), Some(overlay))))
5511            }
5512        }
5513    }
5514
5515    fn destroy_record_materialization(
5516        &self,
5517        record_id: &SessionRecordId,
5518    ) -> Result<bool, NodeFailure> {
5519        let ids = self
5520            .materializations
5521            .lock()
5522            .unwrap_or_else(|poisoned| poisoned.into_inner())
5523            .values()
5524            .filter_map(|record| {
5525                (record.owner()
5526                    == &MaterializationOwner::Record {
5527                        record_id: record_id.clone(),
5528                    })
5529                .then(|| record.id().clone())
5530            })
5531            .collect::<Vec<_>>();
5532        if ids.len() > 1 {
5533            return Err(failure(
5534                NodeFailureCode::BackendOperationFailed,
5535                "managed session has conflicting environment ownership",
5536            ));
5537        }
5538        let Some(id) = ids.first().cloned() else {
5539            return Ok(false);
5540        };
5541        let (terminal_record, mut cleanup_ownership) = {
5542            let _transaction = self
5543                .state_transaction
5544                .lock()
5545                .unwrap_or_else(|poisoned| poisoned.into_inner());
5546            let original_record = self.record(record_id)?;
5547            let mut terminal_record = original_record.clone();
5548            terminal_record.provider_session = None;
5549            terminal_record.active_session = None;
5550            terminal_record.state = ManagedSessionState::Unavailable;
5551            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5552            terminal_record.updated_at_unix_ms = unix_time_ms();
5553            let original_ownership = self
5554                .materializations
5555                .lock()
5556                .unwrap_or_else(|poisoned| poisoned.into_inner())
5557                .get(&id)
5558                .cloned()
5559                .ok_or_else(|| {
5560                    failure(
5561                        NodeFailureCode::BackendOperationFailed,
5562                        "managed session environment ownership disappeared during cleanup",
5563                    )
5564                })?;
5565            let mut cleanup_ownership = original_ownership.clone();
5566            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5567                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5568                    failure(
5569                        NodeFailureCode::BackendOperationFailed,
5570                        "managed session environment cleanup state is invalid",
5571                    )
5572                })?;
5573            }
5574            self.session_records
5575                .lock()
5576                .unwrap_or_else(|poisoned| poisoned.into_inner())
5577                .records
5578                .insert(record_id.clone(), terminal_record.clone());
5579            self.materializations
5580                .lock()
5581                .unwrap_or_else(|poisoned| poisoned.into_inner())
5582                .insert(id.clone(), cleanup_ownership.clone());
5583            if let Err(error) = self.persist_state_locked() {
5584                self.session_records
5585                    .lock()
5586                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5587                    .records
5588                    .insert(record_id.clone(), original_record);
5589                self.materializations
5590                    .lock()
5591                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5592                    .insert(id.clone(), original_ownership);
5593                return Err(persistence_failure(error));
5594            }
5595            (terminal_record, cleanup_ownership)
5596        };
5597        let cleanup = self
5598            .session_environment_materializer
5599            .as_ref()
5600            .ok_or(())
5601            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5602        if cleanup.is_err() {
5603            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5604            self.materializations
5605                .lock()
5606                .unwrap_or_else(|poisoned| poisoned.into_inner())
5607                .insert(id.clone(), cleanup_ownership);
5608            if self.persist_state().is_err() {
5609                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5610            }
5611            return Err(failure(
5612                NodeFailureCode::BackendOperationFailed,
5613                "managed session environment cleanup requires recovery",
5614            ));
5615        }
5616        let _transaction = self
5617            .state_transaction
5618            .lock()
5619            .unwrap_or_else(|poisoned| poisoned.into_inner());
5620        self.remove_record_memory(record_id);
5621        self.materializations
5622            .lock()
5623            .unwrap_or_else(|poisoned| poisoned.into_inner())
5624            .remove(&id);
5625        if let Err(error) = self.persist_state_locked() {
5626            self.session_records
5627                .lock()
5628                .unwrap_or_else(|poisoned| poisoned.into_inner())
5629                .records
5630                .insert(record_id.clone(), terminal_record);
5631            self.materializations
5632                .lock()
5633                .unwrap_or_else(|poisoned| poisoned.into_inner())
5634                .insert(id, cleanup_ownership);
5635            return Err(persistence_failure(error));
5636        }
5637        Ok(true)
5638    }
5639
5640    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5641        let changed = {
5642            let mut materializations = self
5643                .materializations
5644                .lock()
5645                .unwrap_or_else(|poisoned| poisoned.into_inner());
5646            let Some(ownership) = materializations.get_mut(id) else {
5647                return;
5648            };
5649            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5650        };
5651        if changed && self.persist_state().is_err() {
5652            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5653        }
5654    }
5655
5656    fn reconcile_materializations(&self) {
5657        let records = self
5658            .materializations
5659            .lock()
5660            .unwrap_or_else(|poisoned| poisoned.into_inner())
5661            .values()
5662            .cloned()
5663            .collect::<Vec<_>>();
5664        for mut ownership in records {
5665            let result = match (ownership.state(), ownership.owner()) {
5666                (MaterializationState::Preparing, _)
5667                | (MaterializationState::CleanupRequired, _) => {
5668                    self.cleanup_materialization(ownership.id())
5669                }
5670                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5671                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5672                        self.materializations
5673                            .lock()
5674                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5675                            .insert(ownership.id().clone(), ownership.clone());
5676                        self.persist_state().map_err(persistence_failure)
5677                    } else {
5678                        Err(failure(
5679                            NodeFailureCode::BackendOperationFailed,
5680                            "session environment reconciliation failed",
5681                        ))
5682                    }
5683                }
5684                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5685                    .session_environment_materializer
5686                    .as_ref()
5687                    .ok_or_else(|| {
5688                        failure(
5689                            NodeFailureCode::BackendOperationFailed,
5690                            "session environment materializer is unavailable",
5691                        )
5692                    })
5693                    .and_then(|materializer| {
5694                        materializer.revalidate(&ownership).map_err(|_| failure(
5695                            NodeFailureCode::BackendOperationFailed,
5696                            "session materialization revalidation failed",
5697                        ))?;
5698                        let record = self.record(record_id)?;
5699                        if record.environment_profile.as_ref() != ownership.environment_profile()
5700                            || record.bundle.as_ref() != ownership.bundle()
5701                            || record.context.as_ref() != ownership.context()
5702                        {
5703                            return Err(failure(
5704                                NodeFailureCode::BackendOperationFailed,
5705                                "session materialization ownership changed after restart",
5706                            ));
5707                        }
5708                        if let Some(receipt) = ownership.bundle() {
5709                            let catalog = self.bundle_catalog.read()
5710                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5711                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5712                                NodeFailureCode::UnknownBundle,
5713                                "session bundle is unavailable after restart",
5714                            ))?;
5715                            if bundle.receipt() != *receipt {
5716                                return Err(failure(
5717                                    NodeFailureCode::BundleBindingMismatch,
5718                                    "session bundle changed after restart",
5719                                ));
5720                            }
5721                            let layout = self.bundle_layout(
5722                                &record.provider,
5723                                record.mode,
5724                                record.environment_profile.as_ref(),
5725                                bundle,
5726                            )?;
5727                            materializer
5728                                .revalidate_bundle(&ownership, bundle, layout)
5729                                .map_err(|_| failure(
5730                                    NodeFailureCode::BundleMaterializationFailed,
5731                                    "session bundle revalidation failed",
5732                                ))?;
5733                        }
5734                        if let Some(receipt) = ownership.context() {
5735                            let pack = materializer
5736                                .revalidate_context(&ownership, receipt)
5737                                .map_err(|_| {
5738                                    failure(
5739                                        NodeFailureCode::ContextPackMaterializationFailed,
5740                                        "session context revalidation failed",
5741                                    )
5742                                })?;
5743                            self.context_catalog
5744                                .write()
5745                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5746                                .insert(pack)
5747                                .map_err(|_| {
5748                                    failure(
5749                                        NodeFailureCode::ContextPackMaterializationFailed,
5750                                        "session context catalog restore failed",
5751                                    )
5752                                })?;
5753                        }
5754                        Ok(())
5755                    }),
5756                (MaterializationState::RecoveryRequired, _) => Ok(()),
5757            };
5758            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5759                let _ = ownership.mark_recovery_required(unix_time_ms());
5760                self.materializations
5761                    .lock()
5762                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5763                    .insert(ownership.id().clone(), ownership);
5764                if self.persist_state().is_err() {
5765                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5766                }
5767            }
5768        }
5769        let materialized_profiles = self
5770            .environment_materialization_profiles
5771            .read()
5772            .unwrap_or_else(|poisoned| poisoned.into_inner())
5773            .keys()
5774            .cloned()
5775            .collect::<Vec<_>>();
5776        let ownership = self
5777            .materializations
5778            .lock()
5779            .unwrap_or_else(|poisoned| poisoned.into_inner())
5780            .values()
5781            .cloned()
5782            .collect::<Vec<_>>();
5783        let changed = {
5784            let mut session_records = self
5785                .session_records
5786                .lock()
5787                .unwrap_or_else(|poisoned| poisoned.into_inner());
5788            let mut changed = false;
5789            for record in session_records.records.values_mut() {
5790                let environment_profile = record.environment_profile.as_ref();
5791                if environment_profile.is_none()
5792                    && record.bundle.is_none()
5793                    && record.context.is_none()
5794                {
5795                    continue;
5796                }
5797                if record.bundle.is_none()
5798                    && environment_profile.is_some_and(|profile| {
5799                        !materialized_profiles.contains(&profile.profile_id)
5800                    })
5801                {
5802                    continue;
5803                }
5804                let has_exact_ownership = ownership.iter().any(|candidate| {
5805                    candidate.state() == MaterializationState::Ready
5806                        && candidate.environment_profile() == environment_profile
5807                        && candidate.bundle() == record.bundle.as_ref()
5808                        && candidate.context() == record.context.as_ref()
5809                        && candidate.owner()
5810                            == &MaterializationOwner::Record {
5811                                record_id: record.record_id.clone(),
5812                            }
5813                });
5814                if !has_exact_ownership {
5815                    record.provider_session = None;
5816                    record.active_session = None;
5817                    record.state = ManagedSessionState::Unavailable;
5818                    record.last_error = Some(if record.bundle.is_some() {
5819                        "bundle-unavailable".to_owned()
5820                    } else if record.context.is_some() {
5821                        "context-unavailable".to_owned()
5822                    } else {
5823                        "environment-profile-unavailable".to_owned()
5824                    });
5825                    record.updated_at_unix_ms = unix_time_ms();
5826                    changed = true;
5827                }
5828            }
5829            changed
5830        };
5831        if changed && self.persist_state().is_err() {
5832            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5833        }
5834    }
5835
5836    fn replay_spawn_spec(
5837        &self,
5838        spec: &SpawnSpec,
5839        now: Instant,
5840    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5841        let mut cache = self
5842            .spawn_idempotency
5843            .lock()
5844            .unwrap_or_else(|poisoned| poisoned.into_inner());
5845        cache.entries.retain(|_, entry| entry.expires_at > now);
5846        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5847            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5848                return Err(failure(
5849                    NodeFailureCode::SpawnIdempotencyConflict,
5850                    "spawn idempotency key was reused across spawn kinds",
5851                ));
5852            };
5853            if existing != spec {
5854                return Err(failure(
5855                    NodeFailureCode::SpawnIdempotencyConflict,
5856                    "spawn idempotency key was reused with a different specification",
5857                ));
5858            }
5859            return Ok(Some(result.clone()));
5860        }
5861        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5862            return Err(failure(
5863                NodeFailureCode::SpawnIdempotencyCapacity,
5864                "spawn idempotency cache reached its bounded capacity",
5865            ));
5866        }
5867        Ok(None)
5868    }
5869
5870    fn remember_spawn_spec(
5871        &self,
5872        spec: SpawnSpec,
5873        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5874        accepted_at: Instant,
5875    ) {
5876        self
5877            .spawn_idempotency
5878            .lock()
5879            .unwrap_or_else(|poisoned| poisoned.into_inner())
5880            .entries
5881            .insert(
5882                spec.idempotency_key.clone(),
5883                SpawnIdempotencyEntry {
5884                    value: SpawnIdempotencyValue::Standard { spec, result },
5885                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5886                },
5887            );
5888    }
5889
5890    fn claim_harness_mcp_spawn(
5891        &self,
5892        _reservation_id: &HarnessMcpReservationId,
5893        _activation_digest: &HarnessMcpActivationDigest,
5894        spec: &SpawnSpec,
5895        now: Instant,
5896    ) -> Result<(), NodeFailure> {
5897        let mut cache = self.spawn_idempotency.lock()
5898            .unwrap_or_else(|poisoned| poisoned.into_inner());
5899        cache.entries.retain(|_, entry| entry.expires_at > now);
5900        if cache.entries.contains_key(&spec.idempotency_key) {
5901            return Err(failure(
5902                NodeFailureCode::SpawnIdempotencyConflict,
5903                "spawn idempotency key was reused across spawn kinds",
5904            ));
5905        }
5906        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5907            return Err(failure(
5908                NodeFailureCode::SpawnIdempotencyCapacity,
5909                "spawn idempotency cache reached its bounded capacity",
5910            ));
5911        }
5912        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5913            value: SpawnIdempotencyValue::HarnessMcp,
5914            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5915        });
5916        Ok(())
5917    }
5918
5919    fn replay_managed_spawn(
5920        &self,
5921        request: &ManagedWorktreeSpawnRequest,
5922        now: Instant,
5923    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5924        let mut cache = self.spawn_idempotency.lock()
5925            .unwrap_or_else(|poisoned| poisoned.into_inner());
5926        cache.entries.retain(|_, entry| entry.expires_at > now);
5927        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5928            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5929                return Err(failure(
5930                    NodeFailureCode::SpawnIdempotencyConflict,
5931                    "spawn idempotency key was reused across spawn kinds",
5932                ));
5933            };
5934            if existing != request {
5935                return Err(failure(
5936                    NodeFailureCode::SpawnIdempotencyConflict,
5937                    "spawn idempotency key was reused with a different managed specification",
5938                ));
5939            }
5940            return Ok(Some(result.clone()));
5941        }
5942        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5943            return Err(failure(
5944                NodeFailureCode::SpawnIdempotencyCapacity,
5945                "spawn idempotency cache reached its bounded capacity",
5946            ));
5947        }
5948        Ok(None)
5949    }
5950
5951    fn remember_managed_spawn(
5952        &self,
5953        request: ManagedWorktreeSpawnRequest,
5954        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5955        accepted_at: Instant,
5956    ) {
5957        self.spawn_idempotency.lock()
5958            .unwrap_or_else(|poisoned| poisoned.into_inner())
5959            .entries
5960            .insert(
5961                request.spawn_spec.idempotency_key.clone(),
5962                SpawnIdempotencyEntry {
5963                    value: SpawnIdempotencyValue::Managed { request, result },
5964                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5965                },
5966            );
5967    }
5968
5969    fn remember_managed_spawn_attempt(
5970        &self,
5971        durable_v2_key: Option<&SpawnIdempotencyKey>,
5972        request: ManagedWorktreeSpawnRequest,
5973        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5974        accepted_at: Instant,
5975    ) {
5976        if durable_v2_key.is_none() {
5977            self.remember_managed_spawn(request, result, accepted_at);
5978        }
5979    }
5980
5981    fn acquire_durable_managed_spawn_v2(
5982        &self,
5983        request: &ManagedWorktreeSpawnRequestV2,
5984        request_digest: String,
5985    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5986        if self.state_path.is_none() {
5987            return Err(failure(
5988                NodeFailureCode::BackendOperationFailed,
5989                "managed worktree V2 requires durable node state",
5990            ));
5991        }
5992        let _transaction = self.state_transaction
5993            .lock()
5994            .unwrap_or_else(|poisoned| poisoned.into_inner());
5995        let key = request.spawn_spec.idempotency_key.clone();
5996        let mut previous = None;
5997        {
5998            let mut replays = self.managed_spawn_replays
5999                .lock()
6000                .unwrap_or_else(|poisoned| poisoned.into_inner());
6001            if let Some(record) = replays.get_mut(&key) {
6002                if record.request_digest != request_digest {
6003                    return Err(failure(
6004                        NodeFailureCode::SpawnIdempotencyConflict,
6005                        "managed worktree V2 idempotency key was reused with a different request",
6006                    ));
6007                }
6008                match &record.state {
6009                    ManagedWorktreeSpawnReplayStateV10::Pending {
6010                        owner_incarnation_id,
6011                        lease_id: None,
6012                    } if owner_incarnation_id != &self.incarnation_id => {
6013                        previous = Some(record.clone());
6014                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6015                            owner_incarnation_id: self.incarnation_id,
6016                            lease_id: None,
6017                        };
6018                        record.updated_at_unix_ms = unix_time_ms();
6019                    }
6020                    ManagedWorktreeSpawnReplayStateV10::Pending {
6021                        lease_id: None,
6022                        ..
6023                    } => {
6024                        return Err(failure(
6025                            NodeFailureCode::BackendBusy,
6026                            "managed worktree V2 reservation is already active",
6027                        ));
6028                    }
6029                    ManagedWorktreeSpawnReplayStateV10::Pending {
6030                        lease_id: Some(_),
6031                        ..
6032                    } => {
6033                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6034                    }
6035                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6036                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6037                            receipt.clone(),
6038                        ));
6039                    }
6040                }
6041            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6042                return Err(failure(
6043                    NodeFailureCode::SpawnIdempotencyCapacity,
6044                    "durable managed worktree replay ledger reached its bounded capacity",
6045                ));
6046            } else {
6047                let now = unix_time_ms();
6048                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6049                    idempotency_key: key.clone(),
6050                    request_digest,
6051                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6052                    profile_id: request.worktree_profile_id.clone(),
6053                    expected_profile_revision: request.expected_profile_revision.clone(),
6054                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6055                        owner_incarnation_id: self.incarnation_id,
6056                        lease_id: None,
6057                    },
6058                    created_at_unix_ms: now,
6059                    updated_at_unix_ms: now,
6060                });
6061            }
6062        }
6063        if let Err(error) = self.persist_state_locked() {
6064            let mut replays = self.managed_spawn_replays
6065                .lock()
6066                .unwrap_or_else(|poisoned| poisoned.into_inner());
6067            match previous {
6068                Some(previous) => {
6069                    replays.insert(key, previous);
6070                }
6071                None => {
6072                    replays.remove(&key);
6073                }
6074            }
6075            return Err(persistence_failure(error));
6076        }
6077        Ok(DurableManagedSpawnReplayDecision::Reserved)
6078    }
6079
6080    fn link_durable_managed_spawn_v2_lease_locked(
6081        &self,
6082        key: &SpawnIdempotencyKey,
6083        lease_id: &ManagedWorktreeLeaseId,
6084    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6085        let mut replays = self.managed_spawn_replays
6086            .lock()
6087            .unwrap_or_else(|poisoned| poisoned.into_inner());
6088        let record = replays.get_mut(key).ok_or_else(|| {
6089            failure(
6090                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6091                "managed worktree V2 reservation disappeared before allocation",
6092            )
6093        })?;
6094        let previous = record.clone();
6095        match &mut record.state {
6096            ManagedWorktreeSpawnReplayStateV10::Pending {
6097                lease_id: current,
6098                ..
6099            }
6100                if current.is_none() =>
6101            {
6102                *current = Some(lease_id.clone());
6103                record.updated_at_unix_ms = unix_time_ms();
6104                Ok(previous)
6105            }
6106            _ => Err(failure(
6107                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6108                "managed worktree V2 reservation is not allocation-ready",
6109            )),
6110        }
6111    }
6112
6113    fn restore_durable_managed_spawn_v2_locked(
6114        &self,
6115        key: &SpawnIdempotencyKey,
6116        previous: ManagedWorktreeSpawnReplayRecordV10,
6117    ) {
6118        self.managed_spawn_replays
6119            .lock()
6120            .unwrap_or_else(|poisoned| poisoned.into_inner())
6121            .insert(key.clone(), previous);
6122    }
6123
6124    fn commit_durable_managed_spawn_v2(
6125        &self,
6126        request: &ManagedWorktreeSpawnRequestV2,
6127        receipt: ManagedWorktreeSpawnReceipt,
6128    ) -> Result<(), NodeFailure> {
6129        let _transaction = self.state_transaction
6130            .lock()
6131            .unwrap_or_else(|poisoned| poisoned.into_inner());
6132        let key = &request.spawn_spec.idempotency_key;
6133        let previous = {
6134            let mut replays = self.managed_spawn_replays
6135                .lock()
6136                .unwrap_or_else(|poisoned| poisoned.into_inner());
6137            let record = replays.get_mut(key).ok_or_else(|| {
6138                failure(
6139                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6140                    "managed worktree V2 reservation disappeared before commit",
6141                )
6142            })?;
6143            let previous = record.clone();
6144            match &record.state {
6145                ManagedWorktreeSpawnReplayStateV10::Pending {
6146                    lease_id: Some(lease_id),
6147                    ..
6148                } if lease_id == &receipt.lease.lease_id => {}
6149                _ => {
6150                    return Err(failure(
6151                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6152                        "managed worktree V2 reservation is not linked to the spawned lease",
6153                    ));
6154                }
6155            }
6156            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6157            record.updated_at_unix_ms = unix_time_ms();
6158            previous
6159        };
6160        if let Err(error) = self.persist_state_locked() {
6161            self.restore_durable_managed_spawn_v2_locked(key, previous);
6162            return Err(persistence_failure(error));
6163        }
6164        Ok(())
6165    }
6166
6167    fn remove_unallocated_durable_managed_spawn_v2(
6168        &self,
6169        request: &ManagedWorktreeSpawnRequestV2,
6170    ) -> Result<(), NodeFailure> {
6171        let _transaction = self.state_transaction
6172            .lock()
6173            .unwrap_or_else(|poisoned| poisoned.into_inner());
6174        let key = &request.spawn_spec.idempotency_key;
6175        let removed = {
6176            let mut replays = self.managed_spawn_replays
6177                .lock()
6178                .unwrap_or_else(|poisoned| poisoned.into_inner());
6179            match replays.get(key).map(|record| &record.state) {
6180                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6181                    lease_id: None,
6182                    ..
6183                }) => {
6184                    replays.remove(key)
6185                }
6186                _ => None,
6187            }
6188        };
6189        let Some(removed) = removed else {
6190            return Ok(());
6191        };
6192        if let Err(error) = self.persist_state_locked() {
6193            self.managed_spawn_replays
6194                .lock()
6195                .unwrap_or_else(|poisoned| poisoned.into_inner())
6196                .insert(key.clone(), removed);
6197            return Err(persistence_failure(error));
6198        }
6199        Ok(())
6200    }
6201
6202    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6203        if spec.target.node_id != self.node_id {
6204            return Err(failure(
6205                NodeFailureCode::SpawnTargetMismatch,
6206                "spawn target does not match this node",
6207            ));
6208        }
6209        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6210            failure(
6211                NodeFailureCode::UnknownSpawnProfile,
6212                "spawn profile is unavailable on this node",
6213            )
6214        })?;
6215        let resolved = spec.resolve(defaults).map_err(|error| match error {
6216            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6217                NodeFailureCode::SpawnProfileRevisionMismatch,
6218                "spawn profile revision does not match the loaded profile",
6219            ),
6220            _ => failure(
6221                NodeFailureCode::InvalidRequest,
6222                "spawn specification could not be resolved",
6223            ),
6224        })?;
6225        if resolved.target.worktree_id.is_some() {
6226            self.require_worktree_service(&resolved.target.workspace_id)?;
6227        }
6228        let environment_profile = self.resolve_environment_profile(&resolved)?;
6229        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6230        self.resolve_context(&resolved)?;
6231        Ok(resolved)
6232    }
6233
6234    async fn resolve_spawn_workspace(
6235        &self,
6236        resolved: &ResolvedSpawnSpec,
6237        deadline: Instant,
6238    ) -> Result<WorkspaceId, NodeFailure> {
6239        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6240            return Ok(resolved.target.workspace_id.clone());
6241        };
6242        if self.managed_worktrees.lock()
6243            .unwrap_or_else(|poisoned| poisoned.into_inner())
6244            .lease_for_workspace(worktree_id)
6245            .is_some()
6246        {
6247            return Err(failure(
6248                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6249                "managed worktree targets may only be spawned through their lease request",
6250            ));
6251        }
6252        self.require_worktree_service(&resolved.target.workspace_id)?;
6253        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6254        let selected_root = self.workspace_root(worktree_id)?;
6255        let remaining = spawn_deadline_remaining(deadline)?;
6256        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6257            .await
6258            .map_err(|_| {
6259                failure(
6260                    NodeFailureCode::SpawnDeadlineExceeded,
6261                    "Git worktree selection exceeded the spawn deadline",
6262                )
6263            })?
6264            .map_err(git_worktree_failure)?;
6265        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6266        Ok(worktree_id.clone())
6267    }
6268
6269    async fn spawn_from_spec(
6270        &self,
6271        spec: SpawnSpec,
6272    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6273        self.spawn_from_spec_with_proxy(spec, None).await
6274    }
6275
6276    async fn spawn_from_spec_with_proxy(
6277        &self,
6278        spec: SpawnSpec,
6279        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6280    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6281        let accepted_at = Instant::now();
6282        if harness_mcp.is_none() {
6283            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6284                return replayed;
6285            }
6286        }
6287        let resolved = self.resolve_spawn_spec(&spec)?;
6288        let environment_profile = self.resolve_environment_profile(&resolved)?;
6289        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6290        let context = self.resolve_context(&resolved)?;
6291        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6292        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6293        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6294        let spawn_workspace_id = match spawn_workspace_id {
6295            Ok(workspace_id) => workspace_id,
6296            Err(error) => {
6297                let result = Err(error);
6298                if harness_mcp.is_none() {
6299                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6300                }
6301                return result;
6302            }
6303        };
6304        if self.managed_worktrees.lock()
6305            .unwrap_or_else(|poisoned| poisoned.into_inner())
6306            .lease_for_workspace(&spawn_workspace_id)
6307            .is_some()
6308        {
6309            let result = Err(failure(
6310                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6311                "managed worktree targets may only be spawned through their lease request",
6312            ));
6313            if harness_mcp.is_none() {
6314                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6315            }
6316            return result;
6317        }
6318        let result = self
6319            .spawn_session_with_deadline(
6320                spawn_workspace_id,
6321                resolved.provider.clone(),
6322                resolved.mode,
6323                resolved.terminal_size,
6324                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6325                environment_profile.clone(),
6326                bundle.clone(),
6327                context.clone(),
6328                None,
6329                None,
6330                SpawnRecordPolicy::Always,
6331                Some(deadline),
6332                &required_capabilities,
6333                harness_mcp,
6334                resolved.approval_level,
6335                resolved.network_allowlist.clone(),
6336                resolved.browser_profile_id.clone(),
6337            )
6338            .await
6339            .map(|(session, _runtime_policy)| {
6340                resolved.receipt_with_materialization(
6341                    self.incarnation_id,
6342                    session,
6343                    environment_profile,
6344                    bundle,
6345                    context,
6346                )
6347            });
6348        if harness_mcp.is_none() {
6349            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6350        }
6351        result
6352    }
6353
6354    async fn spawn_from_spec_with_harness_mcp(
6355        &self,
6356        reservation_id: HarnessMcpReservationId,
6357        activation_digest: HarnessMcpActivationDigest,
6358        spec: SpawnSpec,
6359        deadline_unix_ms: u64,
6360    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6361        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6362            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6363        })?;
6364        if let Some(receipt) = registry
6365            .replay_spawn(&reservation_id, &activation_digest, &spec)
6366            .map_err(harness_mcp_failure)?
6367        {
6368            return Ok(receipt);
6369        }
6370        let resolved = self.resolve_spawn_spec(&spec)?;
6371        let prepared = registry
6372            .prepare_spawn(
6373                &reservation_id,
6374                &activation_digest,
6375                &spec,
6376                deadline_unix_ms,
6377                resolved.provider.clone(),
6378            )
6379            .map_err(harness_mcp_failure)?;
6380        self.claim_harness_mcp_spawn(
6381            &reservation_id,
6382            &activation_digest,
6383            &spec,
6384            Instant::now(),
6385        )?;
6386        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6387        let mut receipt = match spawn {
6388            Ok(receipt) => receipt,
6389            Err(error) => {
6390                let _ = registry.abort(&reservation_id, &activation_digest);
6391                return Err(error);
6392            }
6393        };
6394        let record_id = self
6395            .session_bindings
6396            .lock()
6397            .unwrap_or_else(|poisoned| poisoned.into_inner())
6398            .get(&receipt.session.session.instance_id)
6399            .and_then(|binding| binding.record_id.clone());
6400        let record_id = match record_id {
6401            Some(record_id) => record_id,
6402            None => {
6403                let rollback = self.remove_session(&receipt.session).await;
6404                let _ = registry.abort(&reservation_id, &activation_digest);
6405                if let Err(rollback_error) = rollback {
6406                    return Err(rollback_error);
6407                }
6408                return Err(failure(
6409                    NodeFailureCode::BindingMismatch,
6410                    "harness MCP spawn did not retain its exact managed record",
6411                ));
6412            }
6413        };
6414        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6415            reservation_id: reservation_id.clone(),
6416            activation_digest: activation_digest.clone(),
6417        });
6418        if let Err(error) = registry.mark_spawned(
6419            &prepared,
6420            receipt.session.clone(),
6421            record_id,
6422            receipt.clone(),
6423        ) {
6424            let rollback = self.remove_session(&receipt.session).await;
6425            let _ = registry.abort(&reservation_id, &activation_digest);
6426            if let Err(rollback_error) = rollback {
6427                return Err(rollback_error);
6428            }
6429            return Err(harness_mcp_failure(error));
6430        }
6431        Ok(receipt)
6432    }
6433
6434    async fn spawn_managed_worktree(
6435        &self,
6436        request: ManagedWorktreeSpawnRequest,
6437    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6438        self.spawn_managed_worktree_inner(request, None).await
6439    }
6440
6441    async fn spawn_managed_worktree_inner(
6442        &self,
6443        request: ManagedWorktreeSpawnRequest,
6444        durable_v2_key: Option<&SpawnIdempotencyKey>,
6445    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6446        let accepted_at = Instant::now();
6447        if durable_v2_key.is_none() {
6448            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6449                return replayed;
6450            }
6451        }
6452        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6453        if request.spawn_spec.target.worktree_id.is_some() {
6454            let result = Err(failure(
6455                NodeFailureCode::InvalidRequest,
6456                "managed worktree spawn must not select a caller-provided worktree",
6457            ));
6458            self.remember_managed_spawn_attempt(
6459                durable_v2_key,
6460                request,
6461                result.clone(),
6462                accepted_at,
6463            );
6464            return result;
6465        }
6466        let environment_profile = self.resolve_environment_profile(&resolved)?;
6467        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6468        let context = self.resolve_context(&resolved)?;
6469        let source_workspace_id = resolved.target.workspace_id.clone();
6470        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6471        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6472        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6473        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6474            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6475            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6476            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6477            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6478        };
6479        let runtime_policy = timeout(
6480            spawn_deadline_remaining(deadline)?,
6481            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6482        ).await.map_err(|_| failure(
6483            NodeFailureCode::SpawnDeadlineExceeded,
6484            "provider admission exceeded the managed spawn deadline",
6485        ))??;
6486        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6487            return Err(failure(
6488                NodeFailureCode::UnsupportedSpawnCapability,
6489                "provider runtime does not admit a required spawn capability",
6490            ));
6491        }
6492        self.ensure_binding_capacity()?;
6493        let source_root = self.workspace_root(&source_workspace_id)?;
6494        profile.validate_for_workspace(&source_root)
6495            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6496        let base_timeout_ms = u64::try_from(
6497            spawn_deadline_remaining(deadline)?.as_millis(),
6498        ).unwrap_or(u64::MAX).max(1);
6499        let base_commit = resolve_base_commit_with_timeout(
6500            &source_root,
6501            profile.base(),
6502            base_timeout_ms,
6503        ).await.map_err(|error| {
6504            if Instant::now() >= deadline || error.message.contains("timed out") {
6505                failure(
6506                    NodeFailureCode::SpawnDeadlineExceeded,
6507                    "managed worktree base resolution exceeded the spawn deadline",
6508                )
6509            } else {
6510                managed_git_worktree_failure(error)
6511            }
6512        })?;
6513
6514        let lease = {
6515            let _transaction = self.state_transaction.lock()
6516                .unwrap_or_else(|poisoned| poisoned.into_inner());
6517            let previous = self.managed_worktrees.lock()
6518                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6519            let lease = self.managed_worktrees.lock()
6520                .unwrap_or_else(|poisoned| poisoned.into_inner())
6521                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6522                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6523            let previous_replay = match durable_v2_key
6524                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6525                .transpose()
6526            {
6527                Ok(previous) => previous,
6528                Err(error) => {
6529                    *self.managed_worktrees.lock()
6530                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6531                    return Err(error);
6532                }
6533            };
6534            if let Err(error) = self.persist_state_locked() {
6535                *self.managed_worktrees.lock()
6536                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6537                if let (Some(key), Some(previous_replay)) =
6538                    (durable_v2_key, previous_replay)
6539                {
6540                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6541                }
6542                return Err(persistence_failure(error));
6543            }
6544            lease
6545        };
6546        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6547        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6548            self.managed_worktrees.lock()
6549                .unwrap_or_else(|poisoned| poisoned.into_inner())
6550                .remove_unmutated(&lease.lease_id);
6551            if self.persist_state().is_err() {
6552                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6553            }
6554            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6555            self.remember_managed_spawn_attempt(
6556                durable_v2_key,
6557                request,
6558                result.clone(),
6559                accepted_at,
6560            );
6561            return result;
6562        }
6563        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6564            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6565            Err(error) => {
6566                self.reconcile_failed_allocation(&lease, &source_root).await;
6567                let result = Err(error);
6568                self.remember_managed_spawn_attempt(
6569                    durable_v2_key,
6570                    request,
6571                    result.clone(),
6572                    accepted_at,
6573                );
6574                return result;
6575            }
6576        };
6577        let created = match create_git_worktree_with_timeout(
6578                &source_root,
6579                &lease.target_root,
6580                &lease.branch,
6581                Some(&base_commit),
6582                mutation_timeout_ms,
6583            ).await {
6584            Ok(created) => created,
6585            Err(error) => {
6586                self.reconcile_failed_allocation(&lease, &source_root).await;
6587                let result = Err(if Instant::now() >= deadline
6588                    || error.message.contains("timed out")
6589                    || error.message.contains("deadline")
6590                {
6591                    failure(
6592                        NodeFailureCode::SpawnDeadlineExceeded,
6593                        "managed worktree creation exceeded the spawn deadline",
6594                    )
6595                } else {
6596                    managed_git_worktree_failure(error)
6597                });
6598                self.remember_managed_spawn_attempt(
6599                    durable_v2_key,
6600                    request,
6601                    result.clone(),
6602                    accepted_at,
6603                );
6604                return result;
6605            }
6606        };
6607        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6608            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6609            let result = Err(failure(
6610                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6611                "created worktree identity did not match its durable lease",
6612            ));
6613            self.remember_managed_spawn_attempt(
6614                durable_v2_key,
6615                request,
6616                result.clone(),
6617                accepted_at,
6618            );
6619            return result;
6620        }
6621        {
6622            let mut registry = self.managed_worktrees.lock()
6623                .unwrap_or_else(|poisoned| poisoned.into_inner());
6624            let current = registry.get_mut(&lease.lease_id)
6625                .expect("allocated managed worktree lease remains present");
6626            current.expected_head = Some(created.head.clone());
6627            current.state = ManagedWorktreeLeaseState::Ready;
6628            current.updated_at_unix_ms = unix_time_ms();
6629        }
6630        if let Err(error) = self.persist_state() {
6631            self.mark_managed_recovery_required(
6632                &lease.lease_id,
6633                ManagedWorktreeCleanupFailure::Backend,
6634            );
6635            let result = Err(persistence_failure(error));
6636            self.remember_managed_spawn_attempt(
6637                durable_v2_key,
6638                request,
6639                result.clone(),
6640                accepted_at,
6641            );
6642            return result;
6643        }
6644        let ready = self.managed_worktrees.lock()
6645            .unwrap_or_else(|poisoned| poisoned.into_inner())
6646            .get(&lease.lease_id)
6647            .expect("ready managed worktree lease remains present")
6648            .snapshot();
6649        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6650        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6651            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6652            let result = Err(error);
6653            self.remember_managed_spawn_attempt(
6654                durable_v2_key,
6655                request,
6656                result.clone(),
6657                accepted_at,
6658            );
6659            return result;
6660        }
6661        let spawn = self.spawn_session_with_deadline(
6662            lease.workspace_id.clone(),
6663            resolved.provider.clone(),
6664            resolved.mode,
6665            resolved.terminal_size,
6666            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6667            environment_profile.clone(),
6668            bundle.clone(),
6669            context.clone(),
6670            Some(lease.lease_id.clone()),
6671            Some(runtime_policy),
6672            SpawnRecordPolicy::Always,
6673            Some(deadline),
6674            &required_capabilities,
6675            None,
6676            resolved.approval_level,
6677            resolved.network_allowlist.clone(),
6678            resolved.browser_profile_id.clone(),
6679        ).await;
6680        let (session, effective_runtime_policy) = match spawn {
6681            Ok(spawned) => spawned,
6682            Err(error) => {
6683                if self
6684                    .cleanup_managed_worktree(&lease.lease_id, true)
6685                    .await
6686                    .is_err()
6687                {
6688                    self.mark_managed_recovery_required(
6689                        &lease.lease_id,
6690                        ManagedWorktreeCleanupFailure::Backend,
6691                    );
6692                }
6693                let result = Err(error);
6694                self.remember_managed_spawn_attempt(
6695                    durable_v2_key,
6696                    request,
6697                    result.clone(),
6698                    accepted_at,
6699                );
6700                return result;
6701            }
6702        };
6703        let (record_id, raw_inventory_record_id) = {
6704            let mut bindings = self.session_bindings.lock()
6705                .unwrap_or_else(|poisoned| poisoned.into_inner());
6706            let binding = bindings.get_mut(&session.session.instance_id)
6707                .expect("successful spawn retains its session binding");
6708            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6709            managed_spawn_record_ownership(binding, effective_runtime_policy)
6710        };
6711        let bound = self.managed_worktrees.lock()
6712            .unwrap_or_else(|poisoned| poisoned.into_inner())
6713            .bind_session(
6714                &lease.lease_id,
6715                ManagedWorktreeSessionHolder {
6716                    incarnation_id: self.incarnation_id,
6717                    instance_id: session.session.instance_id,
6718                    generation: session.session.generation,
6719                },
6720                record_id,
6721                unix_time_ms(),
6722            )
6723            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6724        let snapshot = match bound {
6725            Ok(snapshot) => snapshot,
6726            Err(error) => {
6727                if raw_inventory_record_id
6728                    .as_ref()
6729                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6730                {
6731                    self.mark_managed_recovery_required(
6732                        &lease.lease_id,
6733                        ManagedWorktreeCleanupFailure::Backend,
6734                    );
6735                }
6736                let _ = self.remove_session(&session).await;
6737                self.mark_managed_recovery_required(
6738                    &lease.lease_id,
6739                    ManagedWorktreeCleanupFailure::Backend,
6740                );
6741                let result = Err(error);
6742                self.remember_managed_spawn_attempt(
6743                    durable_v2_key,
6744                    request,
6745                    result.clone(),
6746                    accepted_at,
6747                );
6748                return result;
6749            }
6750        };
6751        if let Err(error) = self.persist_state() {
6752            if raw_inventory_record_id
6753                .as_ref()
6754                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6755            {
6756                self.mark_managed_recovery_required(
6757                    &lease.lease_id,
6758                    ManagedWorktreeCleanupFailure::Backend,
6759                );
6760            }
6761            let _ = self.remove_session(&session).await;
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(persistence_failure(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        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6782        let receipt = managed_spawn_receipt(
6783            &resolved,
6784            self.incarnation_id,
6785            session,
6786            snapshot,
6787            environment_profile,
6788            bundle,
6789            context,
6790        );
6791        let result = Ok(receipt);
6792        self.remember_managed_spawn_attempt(
6793            durable_v2_key,
6794            request,
6795            result.clone(),
6796            accepted_at,
6797        );
6798        result
6799    }
6800
6801    async fn spawn_managed_worktree_v2(
6802        &self,
6803        request: ManagedWorktreeSpawnRequestV2,
6804    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6805        if request.spawn_spec.target.node_id != self.node_id {
6806            return Err(failure(
6807                NodeFailureCode::SpawnTargetMismatch,
6808                "spawn target does not match this node",
6809            ));
6810        }
6811        if request.spawn_spec.target.worktree_id.is_some() {
6812            return Err(failure(
6813                NodeFailureCode::InvalidRequest,
6814                "managed worktree spawn must not select a caller-provided worktree",
6815            ));
6816        }
6817        let request_digest = managed_spawn_request_digest_v2(&request)?;
6818        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6819        match replay {
6820            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6821            DurableManagedSpawnReplayDecision::PendingLinked => {
6822                return Err(failure(
6823                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6824                    "managed worktree V2 reservation has an incomplete durable allocation",
6825                ));
6826            }
6827            DurableManagedSpawnReplayDecision::Reserved => {}
6828        }
6829        let validated = (|| {
6830            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6831            let profile = self.managed_profile(
6832                &resolved.target.workspace_id,
6833                &request.worktree_profile_id,
6834            )?;
6835            if profile.revision() != &request.expected_profile_revision {
6836                return Err(failure(
6837                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6838                    "managed worktree profile revision does not match the loaded profile",
6839                ));
6840            }
6841            Ok(())
6842        })();
6843        match validated {
6844            Ok(()) => {}
6845            Err(error) => {
6846                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6847                return Err(error);
6848            }
6849        }
6850        let legacy = ManagedWorktreeSpawnRequest {
6851            spawn_spec: request.spawn_spec.clone(),
6852            worktree_profile_id: request.worktree_profile_id.clone(),
6853        };
6854        match self
6855            .spawn_managed_worktree_inner(
6856                legacy,
6857                Some(&request.spawn_spec.idempotency_key),
6858            )
6859            .await
6860        {
6861            Ok(receipt) => {
6862                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6863                Ok(receipt)
6864            }
6865            Err(error) => {
6866                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6867                Err(error)
6868            }
6869        }
6870    }
6871
6872    fn managed_profile(
6873        &self,
6874        workspace_id: &WorkspaceId,
6875        profile_id: &WorktreeProfileId,
6876    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6877        if self.worktree_service_modes.get(workspace_id).copied()
6878            != Some(WorktreeServiceMode::Managed)
6879        {
6880            return Err(failure(
6881                NodeFailureCode::UnsupportedSpawnCapability,
6882                "workspace does not enable managed worktrees",
6883            ));
6884        }
6885        self.managed_worktree_profiles.get(workspace_id)
6886            .and_then(|profiles| profiles.get(profile_id))
6887            .cloned()
6888            .ok_or_else(|| failure(
6889                NodeFailureCode::InvalidRequest,
6890                "managed worktree profile is unavailable for this workspace",
6891            ))
6892    }
6893
6894    async fn reconcile_failed_allocation(
6895        &self,
6896        lease: &ManagedWorktreeLeaseRecord,
6897        source_root: &str,
6898    ) {
6899        match list_git_worktrees(source_root).await {
6900            Ok(worktrees) => {
6901                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6902                    None => {
6903                        match std::fs::symlink_metadata(&lease.target_root) {
6904                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6905                                self.managed_worktrees.lock()
6906                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6907                                    .remove_unmutated(&lease.lease_id);
6908                                let _ = self.persist_state();
6909                            }
6910                            _ => self.mark_managed_recovery_required(
6911                                &lease.lease_id,
6912                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6913                            ),
6914                        }
6915                    }
6916                    Some(item) if exact_owned_worktree(
6917                        lease,
6918                        &item.path,
6919                        item.branch.as_deref(),
6920                        &item.head,
6921                    ) => {
6922                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6923                    }
6924                    Some(_) => self.mark_managed_recovery_required(
6925                        &lease.lease_id,
6926                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6927                    ),
6928                }
6929            }
6930            Err(_) => self.mark_managed_recovery_required(
6931                &lease.lease_id,
6932                ManagedWorktreeCleanupFailure::Backend,
6933            ),
6934        }
6935    }
6936
6937    async fn reconcile_managed_worktrees(&self) {
6938        let leases = self.managed_worktrees.lock()
6939            .unwrap_or_else(|poisoned| poisoned.into_inner())
6940            .active_records()
6941            .cloned()
6942            .collect::<Vec<_>>();
6943        for lease in leases {
6944            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6945                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6946                _ => {
6947                    self.mark_managed_recovery_required(
6948                        &lease.lease_id,
6949                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6950                    );
6951                    continue;
6952                }
6953            };
6954            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6955                self.mark_managed_recovery_required(
6956                    &lease.lease_id,
6957                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6958                );
6959                continue;
6960            }
6961            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6962                Ok(root) => root,
6963                Err(_) => {
6964                    self.mark_managed_recovery_required(
6965                        &lease.lease_id,
6966                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6967                    );
6968                    continue;
6969                }
6970            };
6971            let worktrees = match list_git_worktrees(&source_root).await {
6972                Ok(worktrees) => worktrees,
6973                Err(_) => {
6974                    self.mark_managed_recovery_required(
6975                        &lease.lease_id,
6976                        ManagedWorktreeCleanupFailure::Backend,
6977                    );
6978                    continue;
6979                }
6980            };
6981            let listed = worktrees.iter()
6982                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6983            let Some(listed) = listed else {
6984                match std::fs::symlink_metadata(&lease.target_root) {
6985                    Err(error) if error.kind() == io::ErrorKind::NotFound
6986                        && lease.state == ManagedWorktreeLeaseState::Allocating
6987                        && !lease.has_holders() => {
6988                            let removed = self.managed_worktrees.lock()
6989                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6990                                .tombstone(&lease.lease_id, unix_time_ms());
6991                            if self.persist_state().is_ok() && removed.is_some() {
6992                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6993                                    lease_id: lease.lease_id.clone(),
6994                                });
6995                            }
6996                        }
6997                    _ => self.mark_managed_recovery_required(
6998                        &lease.lease_id,
6999                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7000                    ),
7001                }
7002                continue;
7003            };
7004            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7005                &lease,
7006                &listed.path,
7007                listed.branch.as_deref(),
7008                &listed.head,
7009            ) {
7010                self.mark_managed_recovery_required(
7011                    &lease.lease_id,
7012                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7013                );
7014                continue;
7015            }
7016            if !self.workspaces.read()
7017                .unwrap_or_else(|poisoned| poisoned.into_inner())
7018                .contains_key(&lease.workspace_id)
7019            {
7020                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7021                    self.mark_managed_recovery_required(
7022                        &lease.lease_id,
7023                        ManagedWorktreeCleanupFailure::Backend,
7024                    );
7025                    continue;
7026                }
7027            }
7028            let snapshot = {
7029                let mut registry = self.managed_worktrees.lock()
7030                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7031                let current = registry.get_mut(&lease.lease_id)
7032                    .expect("reconciled managed lease remains present");
7033                current.expected_head = Some(listed.head.clone());
7034                current.cleanup_failure = None;
7035                current.state = if current.has_holders() {
7036                    ManagedWorktreeLeaseState::InUse
7037                } else if current.retention == ManagedWorktreeRetention::Retain {
7038                    ManagedWorktreeLeaseState::Retained
7039                } else {
7040                    ManagedWorktreeLeaseState::Ready
7041                };
7042                current.updated_at_unix_ms = unix_time_ms();
7043                current.snapshot()
7044            };
7045            if self.persist_state().is_err() {
7046                self.mark_managed_recovery_required(
7047                    &lease.lease_id,
7048                    ManagedWorktreeCleanupFailure::Backend,
7049                );
7050                continue;
7051            }
7052            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7053            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7054                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7055            }
7056        }
7057    }
7058
7059    /// Crash-recovery startup sweep: durably capture the pack for every
7060    /// `Live` managed record whose most recent session has not yet produced
7061    /// one (recovers a session whose `Exited{0}` event was observed but the
7062    /// Node crashed before the export finished). The reactive fast path for
7063    /// a single just-exited record does not go through this — see
7064    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7065    /// it can capture the exact `SessionAddress` before `publish_control`
7066    /// clears it, rather than re-deriving it from (by then likely already
7067    /// `Dormant`) record state the way a rescan naturally would.
7068    ///
7069    /// Returns as soon as every eligible record's export has been *spawned*,
7070    /// not completed — each one runs on its own detached task (see
7071    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7072    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7073    /// point this is called from `NodeServer::run()`, so nothing could ever
7074    /// drain the history round trip if this awaited it inline here either).
7075    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7076    /// is a silent skip for THIS record — an unusable source or a racing
7077    /// record is not surfaced to any caller.
7078    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7079        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7080            .session_records
7081            .lock()
7082            .unwrap_or_else(|poisoned| poisoned.into_inner())
7083            .records
7084            .values()
7085            .filter(|record| {
7086                record.state == ManagedSessionState::Live
7087                    && record.exported_context.is_none()
7088                    && record.provider_session.is_some()
7089            })
7090            .filter_map(|record| {
7091                record
7092                    .active_session
7093                    .clone()
7094                    .map(|session| (record.record_id.clone(), session))
7095            })
7096            .collect();
7097        for (record_id, session) in candidates {
7098            let shared = Arc::clone(self);
7099            tokio::spawn(async move {
7100                shared
7101                    .reconcile_context_pack_export_for_record(&record_id, session)
7102                    .await;
7103            });
7104        }
7105    }
7106
7107    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7108    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7109    /// round trip this performs (`discover_history`/`load_history`, via
7110    /// `export_context_pack_for_session_record`) can only ever settle once
7111    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7112    /// completion — and `tick()` is exclusively owned by, and only ever
7113    /// called from, that same drive-loop task. Awaiting this inline there
7114    /// would starve `tick()` and this call could never observe its own
7115    /// command complete.
7116    ///
7117    /// Because this now genuinely races the drive loop's own
7118    /// `publish_control` (which downgrades this exact record from `Live` to
7119    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7120    /// triggered this reconcile), the record legitimately may already be
7121    /// `Dormant` by the time this reads it, or may transition mid-flight —
7122    /// `allow_clean_detachment: true` on the downstream export call is what
7123    /// tolerates exactly that transition (and only that transition; see
7124    /// `session_record_export_target_matches`).
7125    async fn reconcile_context_pack_export_for_record(
7126        &self,
7127        record_id: &SessionRecordId,
7128        session: SessionAddress,
7129    ) {
7130        let Ok(record) = self.record(record_id) else {
7131            return;
7132        };
7133        if !matches!(
7134            record.state,
7135            ManagedSessionState::Live | ManagedSessionState::Dormant
7136        ) || record.exported_context.is_some()
7137            || record.provider_session.is_none()
7138        {
7139            return;
7140        }
7141        // Pack bytes are already durably committed inside this call, via
7142        // commit_context_pack_for_session_record's own durable
7143        // write-through (context_pack_store) — no separate step here.
7144        // Unclean exits (non-zero/forced/no loadable history) are already
7145        // rejected by context_pack_source_status_is_usable inside
7146        // materialize_context_pack: this simply returns Err and is skipped.
7147        let Ok(receipt) = self
7148            .export_context_pack_for_session_record(&record.record_id, &session, true)
7149            .await
7150        else {
7151            return;
7152        };
7153        self.set_exported_context(&record.record_id, receipt);
7154    }
7155
7156    /// Records the record's most recent clean-exit pack. No-op (not an
7157    /// error) if the record disappeared, or if it already carries an
7158    /// `exported_context` — first-write-wins, matching the harness-side
7159    /// idempotent reconciliation that consumes this field.
7160    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7161        let transaction = self
7162            .state_transaction
7163            .lock()
7164            .unwrap_or_else(|poisoned| poisoned.into_inner());
7165        let (previous, updated) = {
7166            let mut records = self
7167                .session_records
7168                .lock()
7169                .unwrap_or_else(|poisoned| poisoned.into_inner());
7170            let Some(record) = records.records.get_mut(record_id) else {
7171                return;
7172            };
7173            if record.exported_context.is_some() {
7174                return;
7175            }
7176            let previous = record.clone();
7177            record.exported_context = Some(receipt);
7178            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7179            (previous, record.clone())
7180        };
7181        if self.persist_state_locked().is_err() {
7182            self.session_records
7183                .lock()
7184                .unwrap_or_else(|poisoned| poisoned.into_inner())
7185                .records
7186                .insert(record_id.clone(), previous);
7187            return;
7188        }
7189        drop(transaction);
7190        self.publish_record(updated);
7191    }
7192
7193    /// Resolves the managed record and its exact `SessionAddress` currently
7194    /// bound to `instance_id` at exactly `generation`, used by the reactive
7195    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7196    /// record without a full rescan. Mirrors the lookup already inlined in
7197    /// `publish_control`. Captured synchronously, before `publish_control`
7198    /// for the same event clears the record's own `active_session` — the
7199    /// caller passes the resolved address through to a detached
7200    /// reconciliation task rather than re-deriving it from the record later,
7201    /// after that race has necessarily already happened.
7202    fn managed_record_export_target_for_instance(
7203        &self,
7204        instance_id: AgentInstanceId,
7205        generation: SessionGeneration,
7206    ) -> Option<(SessionRecordId, SessionAddress)> {
7207        let bindings = self
7208            .session_bindings
7209            .lock()
7210            .unwrap_or_else(|poisoned| poisoned.into_inner());
7211        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7212        let record_id = binding.record_id.clone()?;
7213        Some((
7214            record_id,
7215            SessionAddress {
7216                workspace_id: binding.workspace_id.clone(),
7217                session: SessionKey { instance_id, generation },
7218            },
7219        ))
7220    }
7221
7222    fn mark_managed_recovery_required(
7223        &self,
7224        lease_id: &ManagedWorktreeLeaseId,
7225        failure_kind: ManagedWorktreeCleanupFailure,
7226    ) {
7227        let snapshot = {
7228            let mut registry = self.managed_worktrees.lock()
7229                .unwrap_or_else(|poisoned| poisoned.into_inner());
7230            let Some(lease) = registry.get_mut(lease_id) else { return };
7231            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7232            lease.cleanup_failure = Some(failure_kind);
7233            lease.updated_at_unix_ms = unix_time_ms();
7234            lease.snapshot()
7235        };
7236        let _ = self.persist_state();
7237        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7238    }
7239
7240    fn set_persistence_error(&self, error: Option<String>) {
7241        *self
7242            .persistence_error
7243            .write()
7244            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7245            .map(|message| session_registry::sanitized_persistence_summary(&message));
7246    }
7247
7248    fn persist_state(&self) -> io::Result<()> {
7249        let _transaction = self
7250            .state_transaction
7251            .lock()
7252            .unwrap_or_else(|poisoned| poisoned.into_inner());
7253        self.persist_state_locked()
7254    }
7255
7256    fn persist_state_locked(&self) -> io::Result<()> {
7257        let workspaces = self
7258            .workspaces
7259            .read()
7260            .unwrap_or_else(|poisoned| poisoned.into_inner())
7261            .clone();
7262        let records = self
7263            .session_records
7264            .lock()
7265            .unwrap_or_else(|poisoned| poisoned.into_inner())
7266            .records
7267            .values()
7268            .cloned()
7269            .collect::<Vec<_>>();
7270        let mut managed = self.managed_worktrees
7271            .lock()
7272            .unwrap_or_else(|poisoned| poisoned.into_inner());
7273        managed.reattach_record_holders(
7274            &managed_worktree_record_holders(&records),
7275            unix_time_ms(),
7276        );
7277        let materializations = self
7278            .materializations
7279            .lock()
7280            .unwrap_or_else(|poisoned| poisoned.into_inner())
7281            .values()
7282            .cloned()
7283            .collect::<Vec<_>>();
7284        let managed_spawn_replays = self.managed_spawn_replays
7285            .lock()
7286            .unwrap_or_else(|poisoned| poisoned.into_inner())
7287            .values()
7288            .cloned()
7289            .collect::<Vec<_>>();
7290        let result = session_registry::save_v10(
7291            self.state_path.as_deref(),
7292            &self.node_id,
7293            &workspaces,
7294            &records,
7295            &managed.records(),
7296            &managed.tombstones(),
7297            &materializations,
7298            &managed_spawn_replays,
7299        );
7300        match &result {
7301            Ok(warning) => self.set_persistence_error(warning.clone()),
7302            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7303        }
7304        result.map(|_| ())
7305    }
7306
7307    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7308        let _mutation_guard = self.mutation_gate.lock().await;
7309        self.begin_shutdown_locked().await
7310    }
7311
7312    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7313        if self.shutdown.swap(true, Ordering::AcqRel) {
7314            return Ok(());
7315        }
7316        SessionLifecycleHook::on_shutdown(self);
7317        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7318        let mut first_error = None;
7319        for session in &self.handle.snapshot().sessions {
7320            let remaining = deadline.saturating_duration_since(Instant::now());
7321            if remaining.is_zero() {
7322                first_error.get_or_insert_with(|| {
7323                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7324                });
7325                break;
7326            }
7327            if let Err(error) = self
7328                .dispatch_bounded(
7329                    ControlCommand::Stop {
7330                        instance_id: session.instance_id,
7331                        force: true,
7332                    },
7333                    remaining,
7334                )
7335                .await
7336            {
7337                first_error.get_or_insert(error);
7338            }
7339        }
7340        self.shutdown_notify.notify_waiters();
7341        match first_error {
7342            Some(error) => Err(error),
7343            None => Ok(()),
7344        }
7345    }
7346
7347    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7348        let bindings = self
7349            .session_bindings
7350            .lock()
7351            .unwrap_or_else(|poisoned| poisoned.into_inner());
7352        if bindings.len() >= CONTROL_SESSIONS_MAX {
7353            Err(failure(
7354                NodeFailureCode::BackendBusy,
7355                "node session-binding capacity is full; remove an existing session first",
7356            ))
7357        } else {
7358            Ok(())
7359        }
7360    }
7361
7362    /// One-owner workspace/env collision guard: a live session on
7363    /// `workspace_id` with a different `environment_profile` (option
7364    /// equality on the resolved receipt, including Some vs None) blocks
7365    /// another spawn on that same cwd. Same profile may still share the
7366    /// workspace; distinct profiles need distinct workspace ids (managed
7367    /// worktree).
7368    fn ensure_workspace_environment_profile_compatible(
7369        &self,
7370        workspace_id: &WorkspaceId,
7371        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7372    ) -> Result<(), NodeFailure> {
7373        let bindings = self
7374            .session_bindings
7375            .lock()
7376            .unwrap_or_else(|poisoned| poisoned.into_inner());
7377        for binding in bindings.values() {
7378            if &binding.workspace_id != workspace_id {
7379                continue;
7380            }
7381            if binding.environment_profile.as_ref() != environment_profile {
7382                return Err(failure(
7383                    NodeFailureCode::WorkspaceBusy,
7384                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7385                ));
7386            }
7387        }
7388        Ok(())
7389    }
7390
7391    #[cfg(test)]
7392    fn bind_session(
7393        &self,
7394        instance_id: AgentInstanceId,
7395        workspace_id: WorkspaceId,
7396        generation: SessionGeneration,
7397    ) {
7398        self.bind_session_with_policy(
7399            &SessionAddress {
7400                workspace_id,
7401                session: SessionKey { instance_id, generation },
7402            },
7403            ProviderRuntimePolicy::raw_pty(),
7404            None,
7405        );
7406    }
7407
7408    #[cfg(test)]
7409    fn bind_session_with_policy(
7410        &self,
7411        address: &SessionAddress,
7412        runtime_policy: ProviderRuntimePolicy,
7413        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7414    ) {
7415        self.bind_session_with_materialization(
7416            address,
7417            runtime_policy,
7418            environment_profile,
7419            None,
7420            None,
7421            None,
7422        );
7423    }
7424
7425    fn bind_session_with_materialization(
7426        &self,
7427        address: &SessionAddress,
7428        runtime_policy: ProviderRuntimePolicy,
7429        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7430        bundle: Option<ResolvedBundleReceipt>,
7431        context: Option<ResolvedContextPackReceipt>,
7432        materialization_id: Option<MaterializationId>,
7433    ) {
7434        let mut bindings = self
7435            .session_bindings
7436            .lock()
7437            .unwrap_or_else(|poisoned| poisoned.into_inner());
7438        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7439        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7440        bindings.insert(address.session.instance_id, SessionBinding {
7441            workspace_id: address.workspace_id.clone(),
7442            generation: address.session.generation,
7443            runtime_policy,
7444            pending_resume: None,
7445            record_id: None,
7446            managed_worktree_lease_id: None,
7447            environment_profile,
7448            bundle,
7449            context,
7450            materialization_id,
7451        });
7452    }
7453
7454    #[cfg(test)]
7455    fn bind_managed_session(
7456        &self,
7457        address: &SessionAddress,
7458        record_id: SessionRecordId,
7459        runtime_policy: ProviderRuntimePolicy,
7460        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7461    ) {
7462        self.bind_managed_session_with_materialization(
7463            address,
7464            record_id,
7465            runtime_policy,
7466            environment_profile,
7467            None,
7468            None,
7469            None,
7470        );
7471    }
7472
7473    fn bind_managed_session_with_materialization(
7474        &self,
7475        address: &SessionAddress,
7476        record_id: SessionRecordId,
7477        runtime_policy: ProviderRuntimePolicy,
7478        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7479        bundle: Option<ResolvedBundleReceipt>,
7480        context: Option<ResolvedContextPackReceipt>,
7481        materialization_id: Option<MaterializationId>,
7482    ) {
7483        let mut bindings = self
7484            .session_bindings
7485            .lock()
7486            .unwrap_or_else(|poisoned| poisoned.into_inner());
7487        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7488        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7489        bindings.insert(
7490            address.session.instance_id,
7491            SessionBinding {
7492                workspace_id: address.workspace_id.clone(),
7493                generation: address.session.generation,
7494                runtime_policy,
7495                pending_resume: None,
7496                record_id: Some(record_id),
7497                managed_worktree_lease_id: None,
7498                environment_profile,
7499                bundle,
7500                context,
7501                materialization_id,
7502            },
7503        );
7504    }
7505
7506    #[cfg(test)]
7507    fn bind_spawn_session(
7508        &self,
7509        address: &SessionAddress,
7510        provider: AgentId,
7511        mode: SessionMode,
7512        runtime_policy: ProviderRuntimePolicy,
7513        record_policy: SpawnRecordPolicy,
7514        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7515    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7516        self.bind_spawn_session_with_materialization(
7517            address,
7518            provider,
7519            mode,
7520            runtime_policy,
7521            record_policy,
7522            environment_profile,
7523            None,
7524            None,
7525            None,
7526        )
7527    }
7528
7529    fn bind_spawn_session_with_materialization(
7530        &self,
7531        address: &SessionAddress,
7532        provider: AgentId,
7533        mode: SessionMode,
7534        runtime_policy: ProviderRuntimePolicy,
7535        record_policy: SpawnRecordPolicy,
7536        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7537        bundle: Option<ResolvedBundleReceipt>,
7538        context: Option<ResolvedContextPackReceipt>,
7539        materialization_id: Option<MaterializationId>,
7540    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7541        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7542            && !runtime_policy.provider_session_identity
7543        {
7544            self.bind_session_with_materialization(
7545                address,
7546                runtime_policy,
7547                environment_profile,
7548                bundle,
7549                context,
7550                materialization_id,
7551            );
7552            return Ok(None);
7553        }
7554        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7555        let record = self.new_record(
7556            address,
7557            provider,
7558            mode,
7559            active_record_state(runtime_policy.provider_session_identity, false),
7560            None,
7561            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7562            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7563            record_owns_lifecycle.then(|| context.clone()).flatten(),
7564        )?;
7565        let record_id = record.record_id.clone();
7566        let transaction = self
7567            .state_transaction
7568            .lock()
7569            .unwrap_or_else(|poisoned| poisoned.into_inner());
7570        self.insert_record(record.clone())?;
7571        let previous_materialization = if record_owns_lifecycle {
7572            if let Some(materialization_id) = materialization_id.as_ref() {
7573                let mut materializations = self
7574                    .materializations
7575                    .lock()
7576                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7577                let Some(ownership) = materializations.get_mut(materialization_id) else {
7578                    drop(materializations);
7579                    self.remove_record_memory(&record_id);
7580                    return Err(failure(
7581                        NodeFailureCode::BackendOperationFailed,
7582                        "session environment ownership disappeared before binding",
7583                    ));
7584                };
7585                let previous = ownership.clone();
7586                if ownership
7587                    .transfer_to_record(record_id.clone(), unix_time_ms())
7588                    .is_err()
7589                {
7590                    drop(materializations);
7591                    self.remove_record_memory(&record_id);
7592                    return Err(failure(
7593                        NodeFailureCode::BackendOperationFailed,
7594                        "session environment ownership transfer failed",
7595                    ));
7596                }
7597                Some(previous)
7598            } else {
7599                None
7600            }
7601        } else {
7602            None
7603        };
7604        self.bind_managed_session_with_materialization(
7605            address,
7606            record_id.clone(),
7607            runtime_policy,
7608            environment_profile,
7609            bundle,
7610            context,
7611            materialization_id.clone(),
7612        );
7613        if let Err(error) = self.persist_state_locked() {
7614            self.remove_binding(address);
7615            self.remove_record_memory(&record_id);
7616            if let (Some(materialization_id), Some(previous)) =
7617                (materialization_id.as_ref(), previous_materialization)
7618            {
7619                self.materializations
7620                    .lock()
7621                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7622                    .insert(materialization_id.clone(), previous);
7623            }
7624            return Err(persistence_failure(error));
7625        }
7626        drop(transaction);
7627        self.publish_record(record);
7628        Ok(Some(record_id))
7629    }
7630
7631    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7632        for _ in 0..8 {
7633            let bytes = random_nonce().map_err(|error| {
7634                failure(
7635                    NodeFailureCode::BackendOperationFailed,
7636                    &format!("session record identity generation failed: {error}"),
7637                )
7638            })?;
7639            let mut value = String::with_capacity(27);
7640            value.push_str("sr-");
7641            for byte in &bytes[..12] {
7642                use std::fmt::Write as _;
7643                let _ = write!(&mut value, "{byte:02x}");
7644            }
7645            let record_id = SessionRecordId::new(value).map_err(|error| {
7646                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7647            })?;
7648            let records = self
7649                .session_records
7650                .lock()
7651                .unwrap_or_else(|poisoned| poisoned.into_inner());
7652            if !records.records.contains_key(&record_id) {
7653                return Ok(record_id);
7654            }
7655        }
7656        Err(failure(
7657            NodeFailureCode::BackendBusy,
7658            "could not allocate a unique session record ID",
7659        ))
7660    }
7661
7662    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7663        for _ in 0..8 {
7664            let bytes = random_nonce().map_err(|error| {
7665                failure(
7666                    NodeFailureCode::BackendOperationFailed,
7667                    &format!("task identity generation failed: {error}"),
7668                )
7669            })?;
7670            let mut nonce = [0_u8; 12];
7671            nonce.copy_from_slice(&bytes[..12]);
7672            let task_id = TaskId::from_nonce(nonce);
7673            let records = self
7674                .session_records
7675                .lock()
7676                .unwrap_or_else(|poisoned| poisoned.into_inner());
7677            if !records.records.values().any(|record| {
7678                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7679                    == Some(&task_id)
7680            }) {
7681                return Ok(task_id);
7682            }
7683        }
7684        Err(failure(
7685            NodeFailureCode::BackendBusy,
7686            "could not allocate a unique task ID",
7687        ))
7688    }
7689
7690    fn default_record_name(&self, provider: &AgentId) -> String {
7691        let records = self
7692            .session_records
7693            .lock()
7694            .unwrap_or_else(|poisoned| poisoned.into_inner());
7695        let ordinal = records
7696            .records
7697            .values()
7698            .filter(|record| &record.provider == provider)
7699            .count()
7700            .saturating_add(1);
7701        format!("{} #{ordinal}", provider.as_str())
7702    }
7703
7704    fn new_record(
7705        &self,
7706        address: &SessionAddress,
7707        provider: AgentId,
7708        mode: SessionMode,
7709        state: ManagedSessionState,
7710        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7711        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7712        bundle: Option<ResolvedBundleReceipt>,
7713        context: Option<ResolvedContextPackReceipt>,
7714    ) -> Result<ManagedSessionRecord, NodeFailure> {
7715        let canonical_root = self.workspace_root(&address.workspace_id)?;
7716        let now = unix_time_ms();
7717        Ok(ManagedSessionRecord {
7718            record_id: self.allocate_record_id()?,
7719            display_name: self.default_record_name(&provider),
7720            provider,
7721            mode,
7722            state,
7723            workspace_id: address.workspace_id.clone(),
7724            canonical_root: opaque_windows_path(canonical_root),
7725            provider_session,
7726            active_session: Some(address.clone()),
7727            environment_profile,
7728            bundle,
7729            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7730            context,
7731            exported_context: None,
7732            task_binding: None,
7733            created_at_unix_ms: now,
7734            updated_at_unix_ms: now,
7735            last_error: None,
7736        })
7737    }
7738
7739    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7740        let mut records = self
7741            .session_records
7742            .lock()
7743            .unwrap_or_else(|poisoned| poisoned.into_inner());
7744        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7745            return Err(failure(
7746                NodeFailureCode::BackendBusy,
7747                "managed session record capacity is full; forget an inactive record first",
7748            ));
7749        }
7750        if let Some(identity) = record.provider_session.as_ref() {
7751            if records.records.values().any(|candidate| {
7752                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7753                    session_registry::same_provider_session(
7754                        &candidate.provider,
7755                        candidate_identity,
7756                        &record.provider,
7757                        identity,
7758                    )
7759                })
7760            }) {
7761                return Err(failure(
7762                    NodeFailureCode::SessionRecordConflict,
7763                    "provider session is already represented by another managed record",
7764                ));
7765            }
7766        }
7767        match records.records.entry(record.record_id.clone()) {
7768            std::collections::btree_map::Entry::Vacant(entry) => {
7769                entry.insert(record);
7770            }
7771            std::collections::btree_map::Entry::Occupied(_) => {
7772                return Err(failure(
7773                    NodeFailureCode::SessionRecordConflict,
7774                    "session record ID collision",
7775                ));
7776            }
7777        }
7778        Ok(())
7779    }
7780
7781    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7782        self.session_records
7783            .lock()
7784            .unwrap_or_else(|poisoned| poisoned.into_inner())
7785            .records
7786            .get(record_id)
7787            .cloned()
7788            .ok_or_else(|| {
7789                failure(
7790                    NodeFailureCode::UnknownSessionRecord,
7791                    "managed session record does not exist",
7792                )
7793            })
7794    }
7795
7796    fn revalidate_session_record_preview(
7797        &self,
7798        expected: &ManagedSessionRecord,
7799        identity: &ProviderSessionIdentity,
7800    ) -> Result<(), NodeFailure> {
7801        let current = self.record(&expected.record_id)?;
7802        let current_root = self.workspace_root(&current.workspace_id)?;
7803        if current.provider != expected.provider
7804            || current.workspace_id != expected.workspace_id
7805            || current.canonical_root != expected.canonical_root
7806            || current.provider_session.as_ref() != Some(identity)
7807            || !platform::roots_equal(
7808                &current_root,
7809                windows_path_text(&current.canonical_root),
7810            )
7811        {
7812            return Err(failure(
7813                NodeFailureCode::SessionRecordConflict,
7814                "managed session changed while its preview was loading",
7815            ));
7816        }
7817        Ok(())
7818    }
7819
7820    fn revalidate_session_record_context_export(
7821        &self,
7822        expected: &ManagedSessionRecord,
7823        identity: Option<&ProviderSessionIdentity>,
7824        session: &SessionAddress,
7825        allow_clean_detachment: bool,
7826    ) -> Result<(), NodeFailure> {
7827        let current = self.record(&expected.record_id)?;
7828        let current_root = self.workspace_root(&current.workspace_id)?;
7829        let snapshot = self.internal_session_snapshot(session)?;
7830        let binding = self
7831            .session_bindings
7832            .lock()
7833            .unwrap_or_else(|poisoned| poisoned.into_inner())
7834            .get(&session.session.instance_id)
7835            .cloned()
7836            .ok_or_else(|| {
7837                failure(
7838                    NodeFailureCode::SessionRecordConflict,
7839                    "managed session runtime binding disappeared during context export",
7840                )
7841            })?;
7842        if !session_record_context_export_binding_is_exact(
7843            expected,
7844            &current,
7845            identity,
7846            session,
7847            &binding,
7848            &snapshot.agent_id,
7849            &current_root,
7850            allow_clean_detachment,
7851        ) || !session_record_context_export_source_is_usable(
7852            &current,
7853            &snapshot.status,
7854            allow_clean_detachment,
7855        ) {
7856            return Err(failure(
7857                NodeFailureCode::SessionRecordConflict,
7858                "managed session binding changed during context export",
7859            ));
7860        }
7861        Ok(())
7862    }
7863
7864    fn revalidate_loaded_history(
7865        &self,
7866        session: &SessionAddress,
7867        candidate_id: &str,
7868        loaded: &HistorySessionRecord,
7869    ) -> Result<(), NodeFailure> {
7870        let snapshot = self.internal_session_snapshot(session)?;
7871        if snapshot.history.pending.is_some()
7872            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7873            || snapshot.history.loaded.as_ref() != Some(loaded)
7874        {
7875            return Err(failure(
7876                NodeFailureCode::SessionRecordConflict,
7877                "managed session history changed during context export",
7878            ));
7879        }
7880        Ok(())
7881    }
7882
7883    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7884        self.session_records
7885            .lock()
7886            .unwrap_or_else(|poisoned| poisoned.into_inner())
7887            .records
7888            .remove(record_id)
7889    }
7890
7891    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7892        if self.destroy_record_materialization(record_id)? {
7893            self.publish(NodeEvent::SessionRecordRemoved {
7894                record_id: record_id.clone(),
7895            });
7896            return Ok(());
7897        }
7898        let transaction = self
7899            .state_transaction
7900            .lock()
7901            .unwrap_or_else(|poisoned| poisoned.into_inner());
7902        let Some(removed) = self.remove_record_memory(record_id) else {
7903            return Ok(());
7904        };
7905        if let Err(error) = self.persist_state_locked() {
7906            let _ = self.insert_record(removed);
7907            return Err(persistence_failure(error));
7908        }
7909        drop(transaction);
7910        self.publish(NodeEvent::SessionRecordRemoved {
7911            record_id: record_id.clone(),
7912        });
7913        Ok(())
7914    }
7915
7916    fn publish_record(&self, record: ManagedSessionRecord) {
7917        self.publish(NodeEvent::SessionRecordUpserted { record });
7918    }
7919
7920    fn mark_record_error(
7921        &self,
7922        record_id: &SessionRecordId,
7923        message: &str,
7924    ) -> Result<(), NodeFailure> {
7925        let transaction = self
7926            .state_transaction
7927            .lock()
7928            .unwrap_or_else(|poisoned| poisoned.into_inner());
7929        let previous = {
7930            let mut records = self
7931                .session_records
7932                .lock()
7933                .unwrap_or_else(|poisoned| poisoned.into_inner());
7934            let Some(record) = records.records.get_mut(record_id) else {
7935                return Ok(());
7936            };
7937            let previous = record.clone();
7938            record.state = ManagedSessionState::Unavailable;
7939            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7940            record.updated_at_unix_ms = unix_time_ms();
7941            previous
7942        };
7943        if let Err(error) = self.persist_state_locked() {
7944            self.session_records
7945                .lock()
7946                .unwrap_or_else(|poisoned| poisoned.into_inner())
7947                .records
7948                .insert(record_id.clone(), previous);
7949            return Err(persistence_failure(error));
7950        }
7951        let record = self.record(record_id)?;
7952        drop(transaction);
7953        self.publish_record(record);
7954        Ok(())
7955    }
7956
7957    fn rename_session_record(
7958        &self,
7959        record_id: &SessionRecordId,
7960        display_name: String,
7961    ) -> Result<ManagedSessionRecord, NodeFailure> {
7962        validate_display_name(&display_name)
7963            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7964        let _transaction = self
7965            .state_transaction
7966            .lock()
7967            .unwrap_or_else(|poisoned| poisoned.into_inner());
7968        let previous = {
7969            let mut records = self
7970                .session_records
7971                .lock()
7972                .unwrap_or_else(|poisoned| poisoned.into_inner());
7973            let record = records.records.get_mut(record_id).ok_or_else(|| {
7974                failure(
7975                    NodeFailureCode::UnknownSessionRecord,
7976                    "managed session record does not exist",
7977                )
7978            })?;
7979            let previous = record.clone();
7980            record.display_name = display_name;
7981            record.updated_at_unix_ms = unix_time_ms();
7982            previous
7983        };
7984        if let Err(error) = self.persist_state_locked() {
7985            self.session_records
7986                .lock()
7987                .unwrap_or_else(|poisoned| poisoned.into_inner())
7988                .records
7989                .insert(record_id.clone(), previous);
7990            return Err(persistence_failure(error));
7991        }
7992        let updated = self.record(record_id)?;
7993        self.publish_record(updated.clone());
7994        Ok(updated)
7995    }
7996
7997    fn set_session_task(
7998        &self,
7999        record_id: &SessionRecordId,
8000        expected_revision: u64,
8001        target: SessionTaskTargetV1,
8002    ) -> Result<ManagedSessionRecord, NodeFailure> {
8003        let transaction = self
8004            .state_transaction
8005            .lock()
8006            .unwrap_or_else(|poisoned| poisoned.into_inner());
8007        let current = self.record(record_id)?;
8008        let current_task_id = current
8009            .task_binding
8010            .as_ref()
8011            .and_then(|binding| binding.task_id.as_ref());
8012        let current_revision = current
8013            .task_binding
8014            .as_ref()
8015            .map_or(0, |binding| binding.revision);
8016        if expected_revision != current_revision {
8017            return Err(failure(
8018                NodeFailureCode::SessionRecordConflict,
8019                "managed session task binding revision changed",
8020            ));
8021        }
8022        let exact = match &target {
8023            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8024            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8025            SessionTaskTargetV1::New => false,
8026        };
8027        if exact {
8028            return Ok(current);
8029        }
8030        let revision = current_revision.checked_add(1).ok_or_else(|| {
8031            failure(
8032                NodeFailureCode::SessionRecordConflict,
8033                "managed session task binding revision is exhausted",
8034            )
8035        })?;
8036        let task_id = match target {
8037            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8038            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8039            SessionTaskTargetV1::Clear => None,
8040        };
8041        let changed_at_unix_ms = unix_time_ms()
8042            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8043                failure(
8044                    NodeFailureCode::SessionRecordConflict,
8045                    "managed session record timestamp is exhausted",
8046                )
8047            })?);
8048        let updated = {
8049            let mut records = self
8050                .session_records
8051                .lock()
8052                .unwrap_or_else(|poisoned| poisoned.into_inner());
8053            let record = records.records.get_mut(record_id).ok_or_else(|| {
8054                failure(
8055                    NodeFailureCode::UnknownSessionRecord,
8056                    "managed session record does not exist",
8057                )
8058            })?;
8059            record.task_binding = Some(SessionTaskBindingV1 {
8060                revision,
8061                task_id,
8062                changed_at_unix_ms,
8063            });
8064            record.updated_at_unix_ms = changed_at_unix_ms;
8065            record.clone()
8066        };
8067        if let Err(error) = self.persist_state_locked() {
8068            self.session_records
8069                .lock()
8070                .unwrap_or_else(|poisoned| poisoned.into_inner())
8071                .records
8072                .insert(record_id.clone(), current);
8073            return Err(persistence_failure(error));
8074        }
8075        drop(transaction);
8076        self.publish_record(updated.clone());
8077        Ok(updated)
8078    }
8079
8080    fn index_provider_session(
8081        &self,
8082        workspace_id: WorkspaceId,
8083        provider: AgentId,
8084        identity: ProviderSessionIdentity,
8085        display_name: String,
8086    ) -> Result<ManagedSessionRecord, NodeFailure> {
8087        self.index_provider_session_with_policy(
8088            workspace_id,
8089            provider,
8090            identity,
8091            display_name,
8092            false,
8093        )
8094    }
8095
8096    fn index_provider_session_with_policy(
8097        &self,
8098        workspace_id: WorkspaceId,
8099        provider: AgentId,
8100        identity: ProviderSessionIdentity,
8101        display_name: String,
8102        allow_validated_transcript_path: bool,
8103    ) -> Result<ManagedSessionRecord, NodeFailure> {
8104        validate_display_name(&display_name)
8105            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8106        identity
8107            .validate()
8108            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8109        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8110            return Err(failure(
8111                NodeFailureCode::InvalidRequest,
8112                "provider session reference index v1 does not accept transcript paths",
8113            ));
8114        }
8115        let canonical_root = self.workspace_root(&workspace_id)?;
8116        if !self.enabled_providers.contains(&provider) {
8117            return Err(failure(
8118                NodeFailureCode::InvalidRequest,
8119                "provider is not enabled on this node",
8120            ));
8121        }
8122
8123        let transaction = self
8124            .state_transaction
8125            .lock()
8126            .unwrap_or_else(|poisoned| poisoned.into_inner());
8127        {
8128            let records = self
8129                .session_records
8130                .lock()
8131                .unwrap_or_else(|poisoned| poisoned.into_inner());
8132            if let Some(existing) = records.records.values().find(|candidate| {
8133                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8134                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8135                })
8136            }) {
8137                if existing.provider == provider && existing.workspace_id == workspace_id {
8138                    return Ok(existing.clone());
8139                }
8140                return Err(failure(
8141                    NodeFailureCode::SessionRecordConflict,
8142                    "provider session reference conflicts with another provider or workspace",
8143                ));
8144            }
8145            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8146                return Err(failure(
8147                    NodeFailureCode::BackendBusy,
8148                    "managed session record capacity is full; forget an inactive record first",
8149                ));
8150            }
8151        }
8152
8153        let now = unix_time_ms();
8154        let record = ManagedSessionRecord {
8155            record_id: self.allocate_record_id()?,
8156            display_name,
8157            provider,
8158            mode: SessionMode::Pty,
8159            state: ManagedSessionState::Dormant,
8160            workspace_id,
8161            canonical_root: opaque_windows_path(canonical_root),
8162            provider_session: Some(identity),
8163            active_session: None,
8164            environment_profile: None,
8165            bundle: None,
8166            context_id: None,
8167            context: None,
8168            exported_context: None,
8169            task_binding: None,
8170            created_at_unix_ms: now,
8171            updated_at_unix_ms: now,
8172            last_error: None,
8173        };
8174        self.insert_record(record.clone())?;
8175        if let Err(error) = self.persist_state_locked() {
8176            self.remove_record_memory(&record.record_id);
8177            return Err(persistence_failure(error));
8178        }
8179        drop(transaction);
8180        self.publish_record(record.clone());
8181        Ok(record)
8182    }
8183
8184    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8185        self.session_bindings
8186            .lock()
8187            .unwrap_or_else(|poisoned| poisoned.into_inner())
8188            .iter()
8189            .find_map(|(instance_id, binding)| {
8190                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8191                    workspace_id: binding.workspace_id.clone(),
8192                    session: SessionKey {
8193                        instance_id: *instance_id,
8194                        generation: binding.generation,
8195                    },
8196                })
8197            })
8198    }
8199
8200    async fn forget_session_record(
8201        &self,
8202        record_id: &SessionRecordId,
8203    ) -> Result<(), NodeFailure> {
8204        let record = self.record(record_id)?;
8205        if record.active_session.is_some()
8206            || matches!(
8207                record.state,
8208                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8209            )
8210        {
8211            return Err(failure(
8212                NodeFailureCode::SessionRecordBusy,
8213                "stop the managed session before forgetting it",
8214            ));
8215        }
8216        if let Some(address) = self.bound_address_for_record(record_id) {
8217            self.remove_session(&address).await?;
8218        }
8219        self.remove_managed_session_record(record_id, &record.workspace_id).await
8220    }
8221
8222    /// Removes one managed session record from durable state and notifies
8223    /// every listener -- the shared transaction body behind both
8224    /// `forget_session_record`'s operator-driven path (busy-checked above
8225    /// it, and clears any still-live session binding before calling in
8226    /// here) and the retention sweep (`retire_unavailable_session_records`,
8227    /// which only ever selects `ManagedSessionState::Unavailable` records
8228    /// that are already known never-busy and never bound, so it calls
8229    /// straight into this with neither check). Cleans a managed-worktree
8230    /// lease if the record held one, removes the record's environment
8231    /// materialization (or, absent one, removes it from memory directly),
8232    /// persists under `state_transaction` with rollback on persist failure,
8233    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8234    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8235    /// exactly as it does for an operator-driven forget.
8236    async fn remove_managed_session_record(
8237        &self,
8238        record_id: &SessionRecordId,
8239        workspace_id: &WorkspaceId,
8240    ) -> Result<(), NodeFailure> {
8241        let managed_lease_id = self.managed_worktrees.lock()
8242            .unwrap_or_else(|poisoned| poisoned.into_inner())
8243            .lease_for_workspace(workspace_id);
8244        if !self.destroy_record_materialization(record_id)? {
8245            let transaction = self
8246                .state_transaction
8247                .lock()
8248                .unwrap_or_else(|poisoned| poisoned.into_inner());
8249            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8250                failure(
8251                    NodeFailureCode::UnknownSessionRecord,
8252                    "managed session record does not exist",
8253                )
8254            })?;
8255            if let Err(error) = self.persist_state_locked() {
8256                let _ = self.insert_record(removed);
8257                return Err(persistence_failure(error));
8258            }
8259            drop(transaction);
8260        }
8261        self.publish(NodeEvent::SessionRecordRemoved {
8262            record_id: record_id.clone(),
8263        });
8264        if let Some(lease_id) = managed_lease_id {
8265            if let Some(snapshot) = self.managed_worktrees.lock()
8266                .unwrap_or_else(|poisoned| poisoned.into_inner())
8267                .get(&lease_id)
8268                .map(ManagedWorktreeLeaseRecord::snapshot)
8269            {
8270                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8271            }
8272            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8273        }
8274        Ok(())
8275    }
8276
8277    /// Retires `ManagedSessionState::Unavailable` records against the
8278    /// node's configured `session_record_retention` age/keep-N policy.
8279    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8280    /// `Dormant` is resumable and folding it into this sweep would delete
8281    /// records a caller can still act on. Both policy fields default to
8282    /// `0` (disabled); when both are `0` this is a no-op. When both are
8283    /// set, a record is retired only if it fails BOTH tests (older than
8284    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8285    /// workspace); when only one is set, that one alone decides. Called
8286    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8287    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8288    /// loop -- see both call sites.
8289    async fn retire_unavailable_session_records(&self) {
8290        let policy = self.session_record_retention;
8291        let age_enabled = policy.age_ms > 0;
8292        let keep_enabled = policy.keep_per_workspace > 0;
8293        if !age_enabled && !keep_enabled {
8294            return;
8295        }
8296        let now = unix_time_ms();
8297        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8298            self.session_records
8299                .lock()
8300                .unwrap_or_else(|poisoned| poisoned.into_inner())
8301                .records
8302                .values()
8303                .filter(|record| record.state == ManagedSessionState::Unavailable)
8304                .map(|record| {
8305                    (
8306                        record.record_id.clone(),
8307                        record.workspace_id.clone(),
8308                        record.provider.clone(),
8309                        record.updated_at_unix_ms,
8310                    )
8311                })
8312                .collect()
8313        };
8314        if candidates.is_empty() {
8315            return;
8316        }
8317        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8318            BTreeMap::new();
8319        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8320            by_workspace
8321                .entry(workspace_id)
8322                .or_default()
8323                .push((record_id, provider, updated_at_unix_ms));
8324        }
8325        let reason = if age_enabled && keep_enabled {
8326            "age+keep-n"
8327        } else if age_enabled {
8328            "age"
8329        } else {
8330            "keep-n"
8331        };
8332        let mut retired: u64 = 0;
8333        for (workspace_id, mut records) in by_workspace {
8334            // Newest `updated_at_unix_ms` first, so index `>=
8335            // keep_per_workspace` names exactly "beyond the newest N".
8336            records.sort_by(|left, right| right.2.cmp(&left.2));
8337            for (index, (record_id, provider, updated_at_unix_ms)) in
8338                records.into_iter().enumerate()
8339            {
8340                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8341                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8342                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8343                let should_retire = if age_enabled && keep_enabled {
8344                    age_hit && keep_hit
8345                } else {
8346                    age_hit || keep_hit
8347                };
8348                if !should_retire {
8349                    continue;
8350                }
8351                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8352                    Ok(()) => {
8353                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8354                        retired += 1;
8355                        tracing::info!(
8356                            node_id = %self.node_id,
8357                            record_id = %record_id,
8358                            workspace_id = %workspace_id,
8359                            provider = %provider,
8360                            age_ms = record_age_ms,
8361                            reason,
8362                            "managed session record retired"
8363                        );
8364                    }
8365                    Err(error) => tracing::warn!(
8366                        node_id = %self.node_id,
8367                        record_id = %record_id,
8368                        workspace_id = %workspace_id,
8369                        code = ?error.code,
8370                        cause = %error.message,
8371                        "session record retention sweep failed to retire a record"
8372                    ),
8373                }
8374            }
8375        }
8376        if retired > 0 {
8377            let remaining_unavailable = self
8378                .session_records
8379                .lock()
8380                .unwrap_or_else(|poisoned| poisoned.into_inner())
8381                .records
8382                .values()
8383                .filter(|record| record.state == ManagedSessionState::Unavailable)
8384                .count();
8385            tracing::info!(
8386                node_id = %self.node_id,
8387                retired,
8388                remaining_unavailable,
8389                "session record retention sweep completed"
8390            );
8391        }
8392    }
8393
8394    async fn resume_session_record(
8395        &self,
8396        record_id: &SessionRecordId,
8397        terminal_size: gate4agent_types::TerminalSize,
8398        initial_prompt: Option<String>,
8399    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8400        let record = self.record(record_id)?;
8401        if record.active_session.is_some()
8402            || matches!(
8403                record.state,
8404                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8405            )
8406        {
8407            return Err(failure(
8408                NodeFailureCode::SessionRecordBusy,
8409                "managed session already has a live runtime binding",
8410            ));
8411        }
8412        let identity = record.provider_session.clone().ok_or_else(|| {
8413            failure(
8414                NodeFailureCode::SessionRecordNotResumable,
8415                "managed session has no verified provider session identity",
8416            )
8417        })?;
8418        let working_directory = self.workspace_root(&record.workspace_id)?;
8419        if !platform::roots_equal(
8420            &working_directory,
8421            windows_path_text(&record.canonical_root),
8422        ) {
8423            return Err(failure(
8424                NodeFailureCode::SessionRecordConflict,
8425                "managed session workspace root changed; refusing to resume in another directory",
8426            ));
8427        }
8428        {
8429            let records = self
8430                .session_records
8431                .lock()
8432                .unwrap_or_else(|poisoned| poisoned.into_inner());
8433            if records.records.values().any(|candidate| {
8434                candidate.record_id != *record_id
8435                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8436                        session_registry::same_provider_session(
8437                            &candidate.provider,
8438                            candidate_identity,
8439                            &record.provider,
8440                            &identity,
8441                        )
8442                    })
8443                    && (candidate.active_session.is_some()
8444                        || matches!(
8445                            candidate.state,
8446                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8447                        ))
8448            }) {
8449                return Err(failure(
8450                    NodeFailureCode::SessionRecordConflict,
8451                    "provider session is already active through another managed record",
8452                ));
8453            }
8454        }
8455        let runtime_requirement = if initial_prompt.is_some() {
8456            ProviderRuntimeRequirement::ResumeWithPrompt
8457        } else {
8458            ProviderRuntimeRequirement::Resume
8459        };
8460        let runtime_policy = self.admit_provider_runtime(
8461            &record.provider,
8462            runtime_requirement,
8463        )
8464        .await?;
8465        let request = ResumeLaunchRequest {
8466            working_directory,
8467            terminal_size,
8468            initial_prompt,
8469        };
8470        request
8471            .validate()
8472            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8473        self.ensure_binding_capacity()?;
8474        let resolved_materialization = match self.resolve_record_materialization(
8475            record_id,
8476            &record.provider,
8477            record.mode,
8478            record.environment_profile.as_ref(),
8479            record.bundle.as_ref(),
8480            record.context.as_ref(),
8481        ) {
8482            Ok(materialization) => materialization,
8483            Err(error) => {
8484                let label = match error.code {
8485                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8486                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8487                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8488                    _ => "environment-profile-unavailable",
8489                };
8490                self.mark_record_error(record_id, label)?;
8491                return Err(error);
8492            }
8493        };
8494        let (materialization_id, environment_overlay) = match resolved_materialization {
8495            Some((id, overlay)) => (Some(id), overlay),
8496            None => (None, None),
8497        };
8498        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8499        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8500            instance_id,
8501            &record.provider,
8502            record.mode,
8503            record.environment_profile.as_ref(),
8504        ) {
8505            Ok(selection) => selection,
8506            Err(error) => {
8507                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8508                return Err(error);
8509            }
8510        };
8511        let mut instance_overlay = environment_overlay
8512            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8513            .transpose()?
8514            .flatten();
8515        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8516            self.remove_session(&stale_address).await?;
8517        }
8518        let address = SessionAddress {
8519            workspace_id: record.workspace_id.clone(),
8520            session: SessionKey {
8521                instance_id,
8522                generation: SessionGeneration(1),
8523            },
8524        };
8525        let transaction = self
8526            .state_transaction
8527            .lock()
8528            .unwrap_or_else(|poisoned| poisoned.into_inner());
8529        let current = self.record(record_id)?;
8530        if current.active_session.is_some()
8531            || matches!(
8532                current.state,
8533                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8534            )
8535        {
8536            return Err(failure(
8537                NodeFailureCode::SessionRecordBusy,
8538                "managed session became active while resume was being prepared",
8539            ));
8540        }
8541        self.bind_managed_session_with_materialization(
8542            &address,
8543            record_id.clone(),
8544            runtime_policy,
8545            record.environment_profile.clone(),
8546            record.bundle.clone(),
8547            record.context.clone(),
8548            materialization_id,
8549        );
8550        if let Some(selection) = environment_selection.take() {
8551            selection.retain();
8552        }
8553        if let Some(overlay) = instance_overlay.take() {
8554            overlay.retain();
8555        }
8556        drop(transaction);
8557
8558        let transport = match record.mode {
8559            SessionMode::Pty => TransportKind::Pty,
8560            SessionMode::Inline => TransportKind::Pipe,
8561            SessionMode::Acp => TransportKind::Acp,
8562        };
8563        let agent_id = record.provider.clone();
8564        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8565        if let Err(error) = self
8566            .dispatch_bounded(
8567                ControlCommand::Register {
8568                    instance_id,
8569                    agent_id,
8570                    transport,
8571                },
8572                dispatch_timeout,
8573            )
8574            .await
8575        {
8576            if let Some(binding) = self.remove_binding(&address) {
8577                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8578            }
8579            return Err(error);
8580        }
8581        let command = self.prepare_command(ControlCommand::Resume {
8582            instance_id,
8583            target: ResumeTarget::ProviderSession { identity },
8584            runtime_policy,
8585            request,
8586        });
8587        let started_after = self.current_sequence();
8588        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8589            self.rollback_spawn(&address, dispatch_timeout).await?;
8590            return Err(error);
8591        }
8592        if let Err(error) = self.dispatch_envelope(command) {
8593            self.clear_armed_resume(&address);
8594            self.rollback_spawn(&address, dispatch_timeout).await?;
8595            return Err(error);
8596        }
8597        match self
8598            .wait_for_record_resume(&address, started_after)
8599            .await
8600        {
8601            Ok(settled_address) => {
8602                let managed_lease_id = {
8603                    self.managed_worktrees.lock()
8604                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8605                        .lease_for_workspace(&record.workspace_id)
8606                };
8607                if let Some(lease_id) = managed_lease_id {
8608                    {
8609                        let mut bindings = self.session_bindings.lock()
8610                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8611                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8612                            .ok_or_else(|| failure(
8613                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8614                                "resumed managed session lost its exact binding",
8615                            ))?;
8616                        if binding.generation != settled_address.session.generation {
8617                            return Err(failure(
8618                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8619                                "resumed managed session generation diverged",
8620                            ));
8621                        }
8622                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8623                    }
8624                    let snapshot = self.managed_worktrees.lock()
8625                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8626                        .bind_session(
8627                            &lease_id,
8628                            ManagedWorktreeSessionHolder {
8629                                incarnation_id: self.incarnation_id,
8630                                instance_id: settled_address.session.instance_id,
8631                                generation: settled_address.session.generation,
8632                            },
8633                            Some(record_id.clone()),
8634                            unix_time_ms(),
8635                        ).map_err(|message| failure(
8636                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8637                            &message,
8638                        ))?;
8639                    if let Err(error) = self.persist_state() {
8640                        let _ = self.remove_session(&settled_address).await;
8641                        return Err(persistence_failure(error));
8642                    }
8643                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8644                }
8645                Ok((self.record(record_id)?, settled_address))
8646            }
8647            Err(error) => {
8648                if matches!(
8649                    error.code,
8650                    NodeFailureCode::SessionRecordNotResumable
8651                        | NodeFailureCode::BackendOperationFailed
8652                ) {
8653                    if let Some(bound) = self.bound_address_for_record(record_id) {
8654                        let _ = self.remove_session(&bound).await;
8655                    }
8656                }
8657                Err(error)
8658            }
8659        }
8660    }
8661
8662    async fn wait_for_record_resume(
8663        &self,
8664        initial_address: &SessionAddress,
8665        after_sequence: u64,
8666    ) -> Result<SessionAddress, NodeFailure> {
8667        let deadline = Instant::now()
8668            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8669        let mut scan_after = after_sequence;
8670        loop {
8671            let events = self
8672                .history
8673                .lock()
8674                .unwrap_or_else(|poisoned| poisoned.into_inner())
8675                .events
8676                .iter()
8677                .filter(|event| event.sequence > scan_after)
8678                .cloned()
8679                .collect::<Vec<_>>();
8680            for envelope in events {
8681                scan_after = scan_after.max(envelope.sequence);
8682                let NodeEvent::Control { address, event } = envelope.event else {
8683                    continue;
8684                };
8685                if address.session.instance_id != initial_address.session.instance_id {
8686                    continue;
8687                }
8688                match event.event {
8689                    ControlEventKind::Resumed { .. } => return Ok(address),
8690                    ControlEventKind::ResumeDenied { reason } => {
8691                        return Err(failure(
8692                            NodeFailureCode::SessionRecordNotResumable,
8693                            &reason,
8694                        ));
8695                    }
8696                    ControlEventKind::ResumeFailed { message }
8697                    | ControlEventKind::CommandRejected { message } => {
8698                        return Err(failure(
8699                            NodeFailureCode::BackendOperationFailed,
8700                            &message,
8701                        ));
8702                    }
8703                    _ => {}
8704                }
8705            }
8706            if Instant::now() >= deadline {
8707                return Err(failure(
8708                    NodeFailureCode::BackendBusy,
8709                    "managed resume did not settle before the bounded deadline",
8710                ));
8711            }
8712            sleep(Duration::from_millis(5)).await;
8713        }
8714    }
8715
8716    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8717        self.workspaces
8718            .read()
8719            .unwrap_or_else(|poisoned| poisoned.into_inner())
8720            .get(workspace_id)
8721            .cloned()
8722            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8723    }
8724
8725    fn workspace_roots(&self) -> Vec<PathBuf> {
8726        self.workspaces
8727            .read()
8728            .unwrap_or_else(|poisoned| poisoned.into_inner())
8729            .values()
8730            .map(PathBuf::from)
8731            .collect()
8732    }
8733
8734    async fn inspect_workspace(
8735        &self,
8736        workspace_id: WorkspaceId,
8737    ) -> Result<WorkspaceInspection, NodeFailure> {
8738        let _permit = self
8739            .inspection_slots
8740            .clone()
8741            .try_acquire_owned()
8742            .map_err(|_| {
8743                tracing::warn!(
8744                    workspace_id = %workspace_id,
8745                    "workspace inspection rejected: inspection capacity is busy",
8746                );
8747                failure(
8748                    NodeFailureCode::BackendBusy,
8749                    "workspace inspection capacity is busy",
8750                )
8751            })?;
8752        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8753            tracing::warn!(
8754                workspace_id = %workspace_id,
8755                code = ?error.code,
8756                "workspace inspection rejected: workspace is not registered",
8757            );
8758            error
8759        })?;
8760        let tree_root = canonical_root.clone();
8761        let started_at = Instant::now();
8762        let time_budget_ms = workspace_inspection_time_budget_ms();
8763        let entry_cap = workspace_inspection_entry_cap();
8764        let deadline = started_at + Duration::from_millis(time_budget_ms);
8765        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8766            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8767        })
8768        .await
8769        .map_err(|error| {
8770            tracing::warn!(
8771                workspace_id = %workspace_id,
8772                error = %error,
8773                "workspace inspection rejected: walk task failed",
8774            );
8775            failure(
8776                NodeFailureCode::BackendOperationFailed,
8777                &format!("workspace inspection task failed: {error}"),
8778            )
8779        })?;
8780        let (mut git, git_time_budget_exceeded) =
8781            inspect_git_workspace(&canonical_root, deadline).await;
8782        if !git.worktrees.is_empty() {
8783            let registered = self
8784                .workspaces
8785                .read()
8786                .unwrap_or_else(|poisoned| poisoned.into_inner())
8787                .iter()
8788                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8789                .collect::<Vec<_>>();
8790            for worktree in &mut git.worktrees {
8791                worktree.workspace_id = registered
8792                    .iter()
8793                    .find(|(_, root)| {
8794                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8795                    })
8796                    .map(|(workspace_id, _)| workspace_id.clone());
8797            }
8798        }
8799        if git.is_repository {
8800            git.managed_worktree = self
8801                .managed_worktrees
8802                .lock()
8803                .unwrap_or_else(|poisoned| poisoned.into_inner())
8804                .git_scope(&workspace_id, git.branch.as_deref());
8805        }
8806        let truncation = workspace_inspection_truncation(
8807            &walk_budget,
8808            git_time_budget_exceeded,
8809            started_at.elapsed(),
8810        );
8811        if let Some(truncation) = &truncation {
8812            tracing::info!(
8813                workspace_id = %workspace_id,
8814                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8815                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8816                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8817                entries_visited = truncation.entries_visited,
8818                elapsed_ms = truncation.elapsed_ms,
8819                "workspace inspection truncated by its inner budget",
8820            );
8821        }
8822        Ok(WorkspaceInspection {
8823            workspace_id,
8824            entries,
8825            tree_truncated,
8826            git,
8827            truncation,
8828        })
8829    }
8830
8831    async fn browse_host_directories(
8832        &self,
8833        directory: Option<OpaqueHostPath>,
8834        after: Option<OpaqueHostPath>,
8835    ) -> Result<HostDirectoryListing, NodeFailure> {
8836        let permit = self
8837            .inspection_slots
8838            .clone()
8839            .try_acquire_owned()
8840            .map_err(|_| {
8841                failure(
8842                    NodeFailureCode::BackendBusy,
8843                    "host directory browse capacity is busy",
8844                )
8845            })?;
8846        let task = tokio::task::spawn_blocking(move || {
8847            let _permit = permit;
8848            browse_host_directories(directory, after)
8849        });
8850        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8851            .await
8852            .map_err(|_| {
8853                failure(
8854                    NodeFailureCode::HostDirectoryReadTimedOut,
8855                    "host directory browse exceeded its bounded deadline",
8856                )
8857            })?
8858            .map_err(|_| {
8859                failure(
8860                    NodeFailureCode::HostDirectoryReadFailed,
8861                    "host directory browse task failed",
8862                )
8863            })?
8864            .map_err(host_directory_failure)
8865    }
8866
8867    async fn read_workspace_file(
8868        &self,
8869        workspace_id: WorkspaceId,
8870        path: RepositoryPath,
8871    ) -> Result<WorkspaceFileRead, NodeFailure> {
8872        let canonical_root = self.workspace_root(&workspace_id)?;
8873        let permit = self
8874            .inspection_slots
8875            .clone()
8876            .try_acquire_owned()
8877            .map_err(|_| {
8878                failure(
8879                    NodeFailureCode::BackendBusy,
8880                    "workspace file read capacity is busy",
8881                )
8882            })?;
8883        let path_to_read = path.clone();
8884        let task = tokio::task::spawn_blocking(move || {
8885            let _permit = permit;
8886            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8887        });
8888        let content = timeout(
8889            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8890            task,
8891        )
8892        .await
8893        .map_err(|_| {
8894            failure(
8895                NodeFailureCode::RepositoryFileReadTimedOut,
8896                "repository file read exceeded its bounded deadline",
8897            )
8898        })?
8899        .map_err(|_| {
8900            failure(
8901                NodeFailureCode::RepositoryFileReadFailed,
8902                "repository file read task failed",
8903            )
8904        })?
8905        .map_err(workspace_file_failure)?;
8906        let content = match content {
8907            WorkspaceFileBytes::Utf8(text) => {
8908                let revision = Some(workspace_file_revision(text.as_bytes()));
8909                return Ok(WorkspaceFileRead {
8910                    workspace_id,
8911                    path,
8912                    content: WorkspaceFileContent::Utf8 {
8913                        byte_len: u32::try_from(text.len())
8914                            .expect("bounded workspace text length must fit u32"),
8915                        text,
8916                    },
8917                    revision,
8918                });
8919            }
8920            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8921                WorkspaceFileContent::NonUtf8 {
8922                    byte_len: u32::try_from(byte_length)
8923                        .expect("bounded workspace file length must fit u32"),
8924                }
8925            }
8926            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8927                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8928            },
8929        };
8930        Ok(WorkspaceFileRead {
8931            workspace_id,
8932            path,
8933            content,
8934            revision: None,
8935        })
8936    }
8937
8938    async fn write_workspace_file(
8939        &self,
8940        workspace_id: WorkspaceId,
8941        path: RepositoryPath,
8942        expected_revision: WorkspaceFileRevision,
8943        text: String,
8944    ) -> Result<WorkspaceFileRead, NodeFailure> {
8945        let canonical_root = self.workspace_root(&workspace_id)?;
8946        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8947            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8948        })?;
8949        let path_to_write = path.clone();
8950        let expected = expected_revision.as_str().to_owned();
8951        let text_to_write = text.clone();
8952        let task = tokio::task::spawn_blocking(move || {
8953            let _permit = permit;
8954            write_workspace_file_to_disk(
8955                Path::new(&canonical_root),
8956                &path_to_write,
8957                &expected,
8958                &text_to_write,
8959            )
8960        });
8961        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8962            .await
8963            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8964            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8965            .map_err(workspace_file_write_failure)?;
8966        Ok(WorkspaceFileRead {
8967            workspace_id,
8968            path,
8969            content: WorkspaceFileContent::Utf8 {
8970                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8971                text,
8972            },
8973            revision: Some(WorkspaceFileRevision::new(revision)
8974                .expect("workspace writer returns a valid SHA-256 revision")),
8975        })
8976    }
8977
8978    async fn create_workspace_file(
8979        &self,
8980        workspace_id: WorkspaceId,
8981        path: RepositoryPath,
8982    ) -> Result<WorkspaceFileRead, NodeFailure> {
8983        let canonical_root = self.workspace_root(&workspace_id)?;
8984        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8985            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8986        })?;
8987        let path_to_create = path.clone();
8988        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8989        let task_commit_state = Arc::clone(&commit_state);
8990        let task = tokio::task::spawn_blocking(move || {
8991            let _permit = permit;
8992            create_workspace_file_on_disk(
8993                Path::new(&canonical_root),
8994                &path_to_create,
8995                &task_commit_state,
8996            )
8997        });
8998        let revision = settle_workspace_entry_create(
8999            task,
9000            commit_state,
9001            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9002            "repository file create exceeded its bounded deadline",
9003            "repository file create task failed",
9004        )
9005        .await?;
9006        Ok(WorkspaceFileRead {
9007            workspace_id,
9008            path,
9009            content: WorkspaceFileContent::Utf8 {
9010                text: String::new(),
9011                byte_len: 0,
9012            },
9013            revision: Some(WorkspaceFileRevision::new(revision)
9014                .expect("workspace creator returns a valid SHA-256 revision")),
9015        })
9016    }
9017
9018    async fn create_workspace_directory(
9019        &self,
9020        workspace_id: WorkspaceId,
9021        path: RepositoryPath,
9022    ) -> Result<WorkspaceEntry, NodeFailure> {
9023        let canonical_root = self.workspace_root(&workspace_id)?;
9024        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9025            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9026        })?;
9027        let path_to_create = path.clone();
9028        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9029        let task_commit_state = Arc::clone(&commit_state);
9030        let task = tokio::task::spawn_blocking(move || {
9031            let _permit = permit;
9032            create_workspace_directory_on_disk(
9033                Path::new(&canonical_root),
9034                &path_to_create,
9035                &task_commit_state,
9036            )
9037        });
9038        settle_workspace_entry_create(
9039            task,
9040            commit_state,
9041            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9042            "repository directory create exceeded its bounded deadline",
9043            "repository directory create task failed",
9044        )
9045        .await?;
9046        Ok(WorkspaceEntry {
9047            relative_path: path,
9048            kind: WorkspaceEntryKind::Directory,
9049        })
9050    }
9051
9052    async fn read_git_history(
9053        &self,
9054        workspace_id: WorkspaceId,
9055        path: Option<RepositoryPath>,
9056        before: Option<GitObjectId>,
9057        limit: u16,
9058    ) -> Result<GitHistoryPage, NodeFailure> {
9059        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9060            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9061        })?;
9062        let root = self.workspace_root(&workspace_id)?;
9063        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9064    }
9065
9066    async fn read_git_diff(
9067        &self,
9068        workspace_id: WorkspaceId,
9069        request: GitDiffRequest,
9070    ) -> Result<GitDiff, NodeFailure> {
9071        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9072            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9073        })?;
9074        let root = self.workspace_root(&workspace_id)?;
9075        read_git_diff_bounded(&root, request).await
9076    }
9077
9078    fn native_session_route_root(
9079        &self,
9080        route: &NativeSessionCatalogRoute,
9081    ) -> Result<Option<String>, NodeFailure> {
9082        route
9083            .validate()
9084            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9085        match route.scope {
9086            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9087                .workspace_id
9088                .as_ref()
9089                .map(|workspace_id| self.workspace_root(workspace_id))
9090                .transpose(),
9091            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9092        }
9093    }
9094
9095    fn project_native_session_entries(
9096        &self,
9097        route: &NativeSessionCatalogRoute,
9098        entries: Vec<ScopedNativeSessionCatalogEntry>,
9099    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9100        let current_root = route
9101            .workspace_id
9102            .as_ref()
9103            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9104        let records = self
9105            .session_records
9106            .lock()
9107            .unwrap_or_else(|poisoned| poisoned.into_inner());
9108        let mut seen_records = std::collections::HashSet::new();
9109        entries
9110            .into_iter()
9111            .map(|entry| {
9112                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9113                    let matches = records
9114                        .records
9115                        .values()
9116                        .filter(|record| {
9117                            record.provider == route.provider
9118                                && record.workspace_id == *workspace_id
9119                                && current_root.as_deref().is_some_and(|root| {
9120                                    platform::roots_equal(
9121                                        root,
9122                                        windows_path_text(&record.canonical_root),
9123                                    )
9124                                })
9125                                && record.provider_session.as_ref().is_some_and(|identity| {
9126                                    session_registry::same_provider_session(
9127                                        &record.provider,
9128                                        identity,
9129                                        &route.provider,
9130                                        &entry.provider_session,
9131                                    )
9132                                })
9133                        })
9134                        .map(|record| record.record_id.clone())
9135                        .collect::<Vec<_>>();
9136                    (matches.len() == 1
9137                        && seen_records.insert(matches[0].clone()))
9138                        .then(|| matches[0].clone())
9139                });
9140                let metadata = entry.metadata;
9141                crate::protocol::NativeSessionCatalogEntry {
9142                    selection_id: metadata.selection_id,
9143                    title: metadata.title,
9144                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9145                    model: metadata.model,
9146                    message_count: metadata.message_count,
9147                    completed_turn_count: metadata.completed_turn_count,
9148                    external_group: entry.external_group,
9149                    record_id,
9150                }
9151            })
9152            .collect()
9153    }
9154
9155    async fn catalog_native_sessions(
9156        &self,
9157        route: NativeSessionCatalogRoute,
9158        limit: u16,
9159    ) -> Result<(
9160        Vec<crate::protocol::NativeSessionCatalogEntry>,
9161        crate::protocol::NativeSessionCatalogSummary,
9162    ), NodeFailure> {
9163        let canonical_root = self.native_session_route_root(&route)?;
9164        let registered_workspace_roots = self.workspace_roots();
9165        let provider = route.provider.clone();
9166        let scope = route.scope;
9167        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9168            failure(
9169                NodeFailureCode::InvalidRequest,
9170                "native session catalog is not configured",
9171            )
9172        })?;
9173        let _permit = Arc::clone(&self.inspection_slots)
9174            .try_acquire_owned()
9175            .map_err(|_| {
9176                failure(
9177                    NodeFailureCode::BackendBusy,
9178                    "native session catalog capacity is busy",
9179                )
9180            })?;
9181        let page = run_native_session_catalog_operation(
9182            catalog,
9183            "native session catalog",
9184            move |catalog| {
9185                catalog.catalog_initial_for_scope(
9186                    &provider,
9187                    scope,
9188                    canonical_root.as_deref().map(Path::new),
9189                    &registered_workspace_roots,
9190                    limit,
9191                )
9192            },
9193        )
9194        .await?;
9195        let summary = crate::protocol::NativeSessionCatalogSummary {
9196            catalog_revision: page.revision,
9197            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9198            recent_total_count: page.recent_total_count,
9199            older_total_count: page.older_total_count,
9200            recent_next_after_selection_id: page.next_after_selection_id,
9201            recent_has_more: page.remaining_count > 0,
9202        };
9203        Ok((self.project_native_session_entries(&route, page.entries), summary))
9204    }
9205
9206    async fn page_native_sessions(
9207        &self,
9208        route: NativeSessionCatalogRoute,
9209        window: crate::protocol::NativeSessionCatalogWindow,
9210        catalog_revision: u64,
9211        recent_cutoff_unix_ms: u64,
9212        after_selection_id: Option<String>,
9213        limit: u16,
9214    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9215        let canonical_root = self.native_session_route_root(&route)?;
9216        let registered_workspace_roots = self.workspace_roots();
9217        let provider = route.provider.clone();
9218        let scope = route.scope;
9219        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9220            failure(
9221                NodeFailureCode::InvalidRequest,
9222                "native session catalog is not configured",
9223            )
9224        })?;
9225        let _permit = Arc::clone(&self.inspection_slots)
9226            .try_acquire_owned()
9227            .map_err(|_| {
9228                failure(
9229                    NodeFailureCode::BackendBusy,
9230                    "native session catalog capacity is busy",
9231                )
9232            })?;
9233        let page = run_native_session_catalog_operation(
9234            catalog,
9235            "native session catalog page",
9236            move |catalog| {
9237                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9238                    &provider,
9239                    scope,
9240                    canonical_root.as_deref().map(Path::new),
9241                    &registered_workspace_roots,
9242                    window,
9243                    catalog_revision,
9244                    recent_cutoff_unix_ms,
9245                    after_selection_id.as_deref(),
9246                    limit,
9247                ))
9248            },
9249        )
9250        .await?;
9251        let page = page.map_err(|error| match error {
9252            NativeSessionCatalogError::StaleCatalog => failure(
9253                NodeFailureCode::StaleNativeSessionCatalog,
9254                "native session catalog revision or cursor is stale",
9255            ),
9256            _ => failure(
9257                NodeFailureCode::BackendOperationFailed,
9258                "native session catalog page failed",
9259            ),
9260        })?;
9261        let entries = self.project_native_session_entries(&route, page.entries);
9262        Ok(crate::protocol::NativeSessionCatalogPage {
9263            window: page.window,
9264            revision: page.revision,
9265            entries,
9266            next_after_selection_id: page.next_after_selection_id,
9267            remaining_count: page.remaining_count,
9268            has_more: page.remaining_count > 0,
9269        })
9270    }
9271
9272    async fn preview_native_session(
9273        &self,
9274        selection: NativeSessionSelection,
9275        message_limit: u16,
9276    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9277        selection
9278            .validate()
9279            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9280        let canonical_root = self.native_session_route_root(&selection.route)?;
9281        let registered_workspace_roots = self.workspace_roots();
9282        let provider = selection.route.provider.clone();
9283        let scope = selection.route.scope;
9284        let catalog_revision = selection.catalog_revision;
9285        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9286        let selection_id = selection.selection_id.clone();
9287        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9288            failure(
9289                NodeFailureCode::InvalidRequest,
9290                "native session preview is not configured",
9291            )
9292        })?;
9293        let _permit = Arc::clone(&self.inspection_slots)
9294            .try_acquire_owned()
9295            .map_err(|_| {
9296                failure(
9297                    NodeFailureCode::BackendBusy,
9298                    "native session preview capacity is busy",
9299                )
9300            })?;
9301        let preview = run_native_session_catalog_operation(
9302            catalog,
9303            "native session preview",
9304            move |catalog| {
9305                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9306                    &provider,
9307                    scope,
9308                    canonical_root.as_deref().map(Path::new),
9309                    &registered_workspace_roots,
9310                    catalog_revision,
9311                    recent_cutoff_unix_ms,
9312                    &selection_id,
9313                    message_limit,
9314                ))
9315            },
9316        )
9317        .await?;
9318        preview
9319            .map(Into::into)
9320            .map_err(native_session_preview_failure)
9321    }
9322
9323    async fn index_native_session(
9324        &self,
9325        selection: NativeSessionSelection,
9326        display_name: String,
9327    ) -> Result<ManagedSessionRecord, NodeFailure> {
9328        selection
9329            .validate()
9330            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9331        if selection.route.scope
9332            != gate4agent_types::NativeSessionCatalogScope::Workspace
9333        {
9334            return Err(failure(
9335                NodeFailureCode::WorkspaceRegistrationRequired,
9336                "register the external project as a workspace before indexing or resuming it",
9337            ));
9338        }
9339        let workspace_id = selection
9340            .route
9341            .workspace_id
9342            .clone()
9343            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9344        let canonical_root = self
9345            .native_session_route_root(&selection.route)?
9346            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9347        let registered_workspace_roots = self.workspace_roots();
9348        let provider = selection.route.provider.clone();
9349        let catalog_revision = selection.catalog_revision;
9350        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9351        let selection_id = selection.selection_id.clone();
9352        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9353            failure(
9354                NodeFailureCode::InvalidRequest,
9355                "native session index is not configured",
9356            )
9357        })?;
9358        let _permit = Arc::clone(&self.inspection_slots)
9359            .try_acquire_owned()
9360            .map_err(|_| {
9361                failure(
9362                    NodeFailureCode::BackendBusy,
9363                    "native session index capacity is busy",
9364                )
9365            })?;
9366        let resolved = run_native_session_catalog_operation(
9367            catalog,
9368            "native session index resolution",
9369            move |catalog| {
9370                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9371                    &provider,
9372                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9373                    Some(Path::new(&canonical_root)),
9374                    &registered_workspace_roots,
9375                    catalog_revision,
9376                    recent_cutoff_unix_ms,
9377                    &selection_id,
9378                ))
9379            },
9380        )
9381        .await?
9382        .map_err(native_session_preview_failure)?;
9383        self.index_provider_session_with_policy(
9384            workspace_id,
9385            selection.route.provider,
9386            resolved.identity,
9387            display_name,
9388            true,
9389        )
9390    }
9391
9392    async fn preview_native_session_by_session_id(
9393        &self,
9394        workspace_id: WorkspaceId,
9395        provider: AgentId,
9396        session_id: String,
9397        message_limit: u16,
9398    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9399        let canonical_root = self.workspace_root(&workspace_id)?;
9400        let registered_workspace_roots = self.workspace_roots();
9401        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9402            failure(
9403                NodeFailureCode::InvalidRequest,
9404                "native session preview is not configured",
9405            )
9406        })?;
9407        let _permit = Arc::clone(&self.inspection_slots)
9408            .try_acquire_owned()
9409            .map_err(|_| {
9410                failure(
9411                    NodeFailureCode::BackendBusy,
9412                    "native session preview capacity is busy",
9413                )
9414            })?;
9415        run_native_session_catalog_operation(
9416            catalog,
9417            "native session preview",
9418            move |catalog| {
9419                catalog.preview_session_id_for_workspace(
9420                    &provider,
9421                    Path::new(&canonical_root),
9422                    &registered_workspace_roots,
9423                    &session_id,
9424                    message_limit,
9425                )
9426            },
9427        )
9428        .await
9429    }
9430
9431    async fn register_workspace(
9432        &self,
9433        workspace_id: WorkspaceId,
9434        root: String,
9435    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9436        validate_workspace_request_root(&root)?;
9437        let canonicalize_workspace_id = workspace_id.clone();
9438        let workspace = tokio::task::spawn_blocking(move || {
9439            WorkspaceConfig::new(canonicalize_workspace_id, root)
9440        })
9441        .await
9442        .map_err(|error| {
9443            failure(
9444                NodeFailureCode::BackendOperationFailed,
9445                &format!("workspace canonicalization task failed: {error}"),
9446            )
9447        })?
9448        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9449        let transaction = self
9450            .state_transaction
9451            .lock()
9452            .unwrap_or_else(|poisoned| poisoned.into_inner());
9453        let mut workspaces = self
9454            .workspaces
9455            .write()
9456            .unwrap_or_else(|poisoned| poisoned.into_inner());
9457        if workspaces.contains_key(&workspace_id) {
9458            return Err(failure(
9459                NodeFailureCode::DuplicateWorkspaceId,
9460                "workspace ID is already registered",
9461            ));
9462        }
9463        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9464            platform::roots_equal(existing_root, workspace.canonical_root())
9465        }) {
9466            return Err(failure(
9467                NodeFailureCode::DuplicateWorkspaceRoot,
9468                &format!("workspace root is already registered as '{existing_id}'"),
9469            ));
9470        }
9471        {
9472            let records = self
9473                .session_records
9474                .lock()
9475                .unwrap_or_else(|poisoned| poisoned.into_inner());
9476            if let Some(record) = records.records.values().find(|record| {
9477                record.workspace_id == workspace_id
9478                    && !record
9479                        .canonical_root
9480                        .as_utf8()
9481                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9482            }) {
9483                return Err(failure(
9484                    NodeFailureCode::SessionRecordConflict,
9485                    &format!(
9486                        "workspace ID is retained by session record '{}' at another root",
9487                        record.record_id,
9488                    ),
9489                ));
9490            }
9491        }
9492        let snapshot = WorkspaceSnapshot {
9493            workspace_id: workspace_id.clone(),
9494            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9495            sessions: Vec::new(),
9496            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9497            managed_worktree_profiles: Some(WorktreeProfileInventory {
9498                profiles: Vec::new(),
9499            }),
9500        };
9501        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9502        drop(workspaces);
9503        let previous_records = {
9504            let mut records = self
9505                .session_records
9506                .lock()
9507                .unwrap_or_else(|poisoned| poisoned.into_inner());
9508            let previous = records.records.clone();
9509            for record in records.records.values_mut().filter(|record| {
9510                record.workspace_id == workspace_id
9511                    && record
9512                        .canonical_root
9513                        .as_utf8()
9514                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9515            }) {
9516                if record.active_session.is_none() {
9517                    record.state = detached_record_state(record);
9518                }
9519                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9520                    record.last_error = None;
9521                }
9522                record.updated_at_unix_ms = unix_time_ms();
9523            }
9524            previous
9525        };
9526        if let Err(error) = self.persist_state_locked() {
9527            self.workspaces
9528                .write()
9529                .unwrap_or_else(|poisoned| poisoned.into_inner())
9530                .remove(&workspace_id);
9531            self.session_records
9532                .lock()
9533                .unwrap_or_else(|poisoned| poisoned.into_inner())
9534                .records = previous_records;
9535            return Err(persistence_failure(error));
9536        }
9537        let restored = self
9538            .session_records
9539            .lock()
9540            .unwrap_or_else(|poisoned| poisoned.into_inner())
9541            .records
9542            .values()
9543            .filter(|record| record.workspace_id == workspace_id)
9544            .cloned()
9545            .collect::<Vec<_>>();
9546        drop(transaction);
9547        self.publish(NodeEvent::WorkspaceAdded {
9548            workspace: snapshot.clone(),
9549        });
9550        for record in restored {
9551            self.publish_record(record);
9552        }
9553        Ok(snapshot)
9554    }
9555
9556    async fn create_standalone_workspace(
9557        &self,
9558        workspace_id: WorkspaceId,
9559        root: String,
9560        initial_branch: Option<String>,
9561    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9562        validate_workspace_request_root(&root)?;
9563        validate_git_revision("initial branch", initial_branch.as_deref())?;
9564        let preflight_workspace_id = workspace_id.clone();
9565        let preflight_root = root.clone();
9566        let canonical_candidate = tokio::task::spawn_blocking(move || {
9567            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9568        })
9569        .await
9570        .map_err(|_| failure(
9571            NodeFailureCode::BackendOperationFailed,
9572            "standalone workspace root preflight task failed",
9573        ))?
9574        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9575        {
9576            let workspaces = self
9577                .workspaces
9578                .read()
9579                .unwrap_or_else(|poisoned| poisoned.into_inner());
9580            if workspaces.contains_key(&workspace_id) {
9581                return Err(failure(
9582                    NodeFailureCode::DuplicateWorkspaceId,
9583                    "workspace ID is already registered",
9584                ));
9585            }
9586            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9587                platform::roots_equal(existing_root, &canonical_candidate)
9588            }) {
9589                return Err(failure(
9590                    NodeFailureCode::DuplicateWorkspaceRoot,
9591                    &format!("workspace root is already registered as '{existing_id}'"),
9592                ));
9593            }
9594        }
9595        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9596            .await
9597            .map_err(standalone_workspace_failure)?;
9598        prepared
9599            .verify_for_registration()
9600            .await
9601            .map_err(standalone_workspace_failure)?;
9602        match self
9603            .register_workspace(workspace_id, prepared.root().to_owned())
9604            .await
9605        {
9606            Ok(workspace) => Ok(workspace),
9607            Err(registration_error) => {
9608                if prepared.compensate_registration_failure().await {
9609                    Err(registration_error)
9610                } else {
9611                    Err(failure(
9612                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9613                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9614                    ))
9615                }
9616            }
9617        }
9618    }
9619
9620    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9621        let bindings = self
9622            .session_bindings
9623            .lock()
9624            .unwrap_or_else(|poisoned| poisoned.into_inner());
9625        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9626            return Err(failure(
9627                NodeFailureCode::WorkspaceBusy,
9628                "workspace retains a bound session; remove every session before unregistering it",
9629            ));
9630        }
9631        drop(bindings);
9632        let transaction = self
9633            .state_transaction
9634            .lock()
9635            .unwrap_or_else(|poisoned| poisoned.into_inner());
9636        let mut workspaces = self
9637            .workspaces
9638            .write()
9639            .unwrap_or_else(|poisoned| poisoned.into_inner());
9640        if !workspaces.contains_key(workspace_id) {
9641            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9642        }
9643        if workspaces.len() == 1 {
9644            return Err(failure(
9645                NodeFailureCode::LastWorkspace,
9646                "node must retain at least one workspace",
9647            ));
9648        }
9649        let removed_root = workspaces
9650            .remove(workspace_id)
9651            .expect("workspace presence was validated");
9652        drop(workspaces);
9653        let previous_records = {
9654            let mut records = self
9655                .session_records
9656                .lock()
9657                .unwrap_or_else(|poisoned| poisoned.into_inner());
9658            let previous = records.records.clone();
9659            for record in records
9660                .records
9661                .values_mut()
9662                .filter(|record| &record.workspace_id == workspace_id)
9663            {
9664                record.active_session = None;
9665                record.state = ManagedSessionState::Unavailable;
9666                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9667                record.updated_at_unix_ms = unix_time_ms();
9668            }
9669            previous
9670        };
9671        if let Err(error) = self.persist_state_locked() {
9672            self.workspaces
9673                .write()
9674                .unwrap_or_else(|poisoned| poisoned.into_inner())
9675                .insert(workspace_id.clone(), removed_root);
9676            self.session_records
9677                .lock()
9678                .unwrap_or_else(|poisoned| poisoned.into_inner())
9679                .records = previous_records;
9680            return Err(persistence_failure(error));
9681        }
9682        let unavailable = self
9683            .session_records
9684            .lock()
9685            .unwrap_or_else(|poisoned| poisoned.into_inner())
9686            .records
9687            .values()
9688            .filter(|record| &record.workspace_id == workspace_id)
9689            .cloned()
9690            .collect::<Vec<_>>();
9691        drop(transaction);
9692        self.publish(NodeEvent::WorkspaceRemoved {
9693            workspace_id: workspace_id.clone(),
9694        });
9695        for record in unavailable {
9696            self.publish_record(record);
9697        }
9698        Ok(())
9699    }
9700
9701    async fn create_worktree(
9702        &self,
9703        source_workspace_id: WorkspaceId,
9704        workspace_id: WorkspaceId,
9705        target_root: String,
9706        branch: String,
9707        base: Option<String>,
9708    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9709        self.require_worktree_service(&source_workspace_id)?;
9710        validate_workspace_request_root(&target_root)?;
9711        validate_git_revision("worktree base", base.as_deref())?;
9712        let source_root = self.workspace_root(&source_workspace_id)?;
9713        {
9714            let workspaces = self
9715                .workspaces
9716                .read()
9717                .unwrap_or_else(|poisoned| poisoned.into_inner());
9718            if workspaces.contains_key(&workspace_id) {
9719                return Err(failure(
9720                    NodeFailureCode::DuplicateWorkspaceId,
9721                    "workspace ID is already registered",
9722                ));
9723            }
9724            if let Some((existing_id, _)) = workspaces
9725                .iter()
9726                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9727            {
9728                return Err(failure(
9729                    NodeFailureCode::DuplicateWorkspaceRoot,
9730                    &format!("workspace root is already registered as '{existing_id}'"),
9731                ));
9732            }
9733        }
9734        let mut worktree = create_git_worktree(
9735            &source_root,
9736            &target_root,
9737            &branch,
9738            base.as_deref(),
9739        )
9740        .await
9741        .map_err(git_worktree_failure)?;
9742        let workspace = match self
9743            .register_workspace(workspace_id, worktree.path.clone())
9744            .await
9745        {
9746            Ok(workspace) => workspace,
9747            Err(registration_error) => {
9748                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9749                    return Err(failure(
9750                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9751                        "worktree registration failed and compensation requires recovery",
9752                    ));
9753                }
9754                return Err(failure(
9755                    registration_error.code,
9756                    &registration_error.message,
9757                ));
9758            }
9759        };
9760        worktree.workspace_id = Some(workspace.workspace_id.clone());
9761        Ok((protocol_worktree(worktree), workspace))
9762    }
9763
9764    async fn remove_worktree(
9765        &self,
9766        source_workspace_id: WorkspaceId,
9767        target_root: String,
9768    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9769        self.require_worktree_service(&source_workspace_id)?;
9770        validate_workspace_request_root(&target_root)?;
9771        let source_root = self.workspace_root(&source_workspace_id)?;
9772        let target_root = normalize_worktree_removal_target(&target_root)
9773            .map_err(git_worktree_failure)?;
9774        let target = list_git_worktrees(&source_root)
9775            .await
9776            .map_err(git_worktree_failure)?
9777            .into_iter()
9778            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9779            .ok_or_else(|| {
9780                failure(
9781                    NodeFailureCode::WorktreeProtected,
9782                    "refusing to remove a path that is not in Git's worktree listing",
9783                )
9784            })?;
9785        let registered_workspace_id = {
9786            let workspaces = self
9787                .workspaces
9788                .read()
9789                .unwrap_or_else(|poisoned| poisoned.into_inner());
9790            let matched = workspaces
9791                .iter()
9792                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9793                .map(|(workspace_id, _)| workspace_id.clone());
9794            if matched.is_some() && workspaces.len() == 1 {
9795                return Err(failure(
9796                    NodeFailureCode::LastWorkspace,
9797                    "node must retain at least one workspace",
9798                ));
9799            }
9800            matched
9801        };
9802        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9803            let bindings = self
9804                .session_bindings
9805                .lock()
9806                .unwrap_or_else(|poisoned| poisoned.into_inner());
9807            if bindings
9808                .values()
9809                .any(|binding| &binding.workspace_id == workspace_id)
9810            {
9811                return Err(failure(
9812                    NodeFailureCode::WorkspaceBusy,
9813                    "worktree workspace retains a bound session; remove every session first",
9814                ));
9815            }
9816        }
9817        remove_git_worktree(&source_root, &target.path)
9818            .await
9819            .map_err(git_worktree_failure)?;
9820        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9821            self.unregister_workspace(workspace_id)
9822                .map_err(|error| failure(error.code, &error.message))?;
9823        }
9824        Ok(registered_workspace_id)
9825    }
9826
9827    async fn cleanup_managed_worktree(
9828        &self,
9829        lease_id: &ManagedWorktreeLeaseId,
9830        explicit: bool,
9831    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9832        let lease = self.managed_worktrees.lock()
9833            .unwrap_or_else(|poisoned| poisoned.into_inner())
9834            .get(lease_id)
9835            .cloned()
9836            .ok_or_else(|| failure(
9837                NodeFailureCode::UnknownManagedWorktreeLease,
9838                "managed worktree lease does not exist",
9839            ))?;
9840        if lease.state == ManagedWorktreeLeaseState::Removed {
9841            return Ok(lease.snapshot());
9842        }
9843        if lease.has_holders() {
9844            return Err(failure(
9845                NodeFailureCode::ManagedWorktreeBusy,
9846                "managed worktree lease still has a session or durable-record holder",
9847            ));
9848        }
9849        if self
9850            .materializations
9851            .lock()
9852            .unwrap_or_else(|poisoned| poisoned.into_inner())
9853            .values()
9854            .any(|record| record.managed_lease_id() == Some(lease_id))
9855        {
9856            return Err(failure(
9857                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9858                "managed worktree cleanup is blocked by session environment ownership",
9859            ));
9860        }
9861        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9862            let snapshot = {
9863                let mut registry = self.managed_worktrees.lock()
9864                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9865                let current = registry.get_mut(lease_id)
9866                    .expect("managed worktree lease remains present");
9867                current.state = ManagedWorktreeLeaseState::Retained;
9868                current.cleanup_failure = None;
9869                current.updated_at_unix_ms = unix_time_ms();
9870                current.snapshot()
9871            };
9872            if self.persist_state().is_err() {
9873                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9874            }
9875            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9876            return Ok(snapshot);
9877        }
9878        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9879        if profile.revision() != &lease.profile_revision {
9880            self.mark_managed_recovery_required(
9881                lease_id,
9882                ManagedWorktreeCleanupFailure::OwnershipConflict,
9883            );
9884            return Err(failure(
9885                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9886                "managed worktree profile revision changed",
9887            ));
9888        }
9889        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9890            self.mark_managed_recovery_required(
9891                lease_id,
9892                ManagedWorktreeCleanupFailure::OwnershipConflict,
9893            );
9894            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9895        })?;
9896        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9897        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9898            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9899            managed_git_worktree_failure(error)
9900        })?;
9901        let target = listed.iter()
9902            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9903        if let Some(target) = target {
9904            if target.is_main || target.is_bare || !exact_owned_worktree(
9905                &lease,
9906                &target.path,
9907                target.branch.as_deref(),
9908                &target.head,
9909            ) {
9910                self.mark_managed_recovery_required(
9911                    lease_id,
9912                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9913                );
9914                return Err(failure(
9915                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9916                    "Git worktree identity no longer matches the managed lease",
9917                ));
9918            }
9919            if target.prunable {
9920                self.mark_managed_cleanup_blocked(
9921                    lease_id,
9922                    ManagedWorktreeCleanupFailure::Prunable,
9923                );
9924                return Err(failure(
9925                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9926                    "managed worktree is prunable and requires recovery",
9927                ));
9928            }
9929            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9930                let failure_kind = match error.kind {
9931                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9932                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9933                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9934                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9935                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9936                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9937                };
9938                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9939                    self.mark_managed_recovery_required(lease_id, failure_kind);
9940                } else {
9941                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9942                }
9943                return Err(managed_git_worktree_failure(error));
9944            }
9945        } else {
9946            match std::fs::symlink_metadata(&lease.target_root) {
9947                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9948                Ok(_) => {
9949                    self.mark_managed_recovery_required(
9950                        lease_id,
9951                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9952                    );
9953                    return Err(failure(
9954                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9955                        "managed target exists but is not owned by Git",
9956                    ));
9957                }
9958                Err(_) => {
9959                    self.mark_managed_cleanup_blocked(
9960                        lease_id,
9961                        ManagedWorktreeCleanupFailure::Backend,
9962                    );
9963                    return Err(failure(
9964                        NodeFailureCode::BackendOperationFailed,
9965                        "managed target identity could not be inspected",
9966                    ));
9967                }
9968            }
9969        }
9970        let registered = self.workspaces.read()
9971            .unwrap_or_else(|poisoned| poisoned.into_inner())
9972            .contains_key(&lease.workspace_id);
9973        if registered {
9974            self.unregister_workspace(&lease.workspace_id)?;
9975        }
9976        let removed = {
9977            self.managed_worktrees.lock()
9978                .unwrap_or_else(|poisoned| poisoned.into_inner())
9979                .tombstone(lease_id, unix_time_ms())
9980                .expect("managed worktree lease remains present during cleanup")
9981        };
9982        self.persist_state().map_err(persistence_failure)?;
9983        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9984        Ok(removed.snapshot())
9985    }
9986
9987    fn mark_managed_cleanup_blocked(
9988        &self,
9989        lease_id: &ManagedWorktreeLeaseId,
9990        failure_kind: ManagedWorktreeCleanupFailure,
9991    ) {
9992        let snapshot = {
9993            let mut registry = self.managed_worktrees.lock()
9994                .unwrap_or_else(|poisoned| poisoned.into_inner());
9995            let Some(lease) = registry.get_mut(lease_id) else { return };
9996            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9997            lease.cleanup_failure = Some(failure_kind);
9998            lease.updated_at_unix_ms = unix_time_ms();
9999            lease.snapshot()
10000        };
10001        let _ = self.persist_state();
10002        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10003    }
10004
10005    fn require_worktree_service(
10006        &self,
10007        source_workspace_id: &WorkspaceId,
10008    ) -> Result<(), NodeFailure> {
10009        if self.managed_worktrees.lock()
10010            .unwrap_or_else(|poisoned| poisoned.into_inner())
10011            .lease_for_workspace(source_workspace_id)
10012            .is_some()
10013        {
10014            return Err(failure(
10015                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10016                "managed worktree targets are internal-only workspaces",
10017            ));
10018        }
10019        match self
10020            .worktree_service_modes
10021            .get(source_workspace_id)
10022            .copied()
10023            .unwrap_or(WorktreeServiceMode::Manual)
10024        {
10025            WorktreeServiceMode::Manual => Ok(()),
10026            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10027                NodeFailureCode::UnsupportedSpawnCapability,
10028                "Git worktree service mode is unavailable in this node build",
10029            )),
10030        }
10031    }
10032
10033    fn managed_reservation_conflict(
10034        &self,
10035        workspace_id: Option<&WorkspaceId>,
10036        root: Option<&str>,
10037    ) -> bool {
10038        self.managed_worktrees.lock()
10039            .unwrap_or_else(|poisoned| poisoned.into_inner())
10040            .active_records()
10041            .any(|lease| {
10042                workspace_id.is_some_and(|workspace_id| {
10043                    workspace_id == &lease.source_workspace_id
10044                        || workspace_id == &lease.workspace_id
10045                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10046            })
10047    }
10048
10049    fn reject_managed_reservation(
10050        &self,
10051        workspace_id: Option<&WorkspaceId>,
10052        root: Option<&str>,
10053    ) -> Result<(), NodeFailure> {
10054        if self.managed_reservation_conflict(workspace_id, root) {
10055            Err(failure(
10056                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10057                "managed worktree ownership may only be mutated through its lease cleanup",
10058            ))
10059        } else {
10060            Ok(())
10061        }
10062    }
10063
10064    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10065        self.validate_address(address)?;
10066        self.workspace_root(&address.workspace_id)
10067    }
10068
10069    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10070    /// `require_policy`'s own doc on those two requirements), so they keep
10071    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10072    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10073    /// `false` on the `require_policy` call below for them because a PTY
10074    /// resume never needs those facts, not because this method cannot look
10075    /// them up.
10076    ///
10077    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10078    /// the SAME `binding.runtime_policy` every other requirement reads here
10079    /// (one computed policy, read by every gate). That policy is no longer
10080    /// PTY-terminal-verification-shaped for an ACP session --
10081    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10082    /// `provider_runtime::policy_for_transport` -- so consulting it for
10083    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10084    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10085    /// even though a prompt is an ACP session's only interface). Pipe keeps
10086    /// reading the same PTY-probe-derived value it always has --
10087    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10088    /// its own doc for why).
10089    ///
10090    /// What this method still adds on top, for ACP/Pipe only, is a
10091    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10092    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10093    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10094    /// read off the same live `SessionSnapshot` state `require_acp_session`
10095    /// already reads for the ACP control verbs -- `TurnStarted` observed
10096    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10097    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10098    /// its fate in vendor-specific behaviour this node cannot observe (one
10099    /// agent may queue it, another drop it, another interleave it), so this
10100    /// refuses it outright rather than gambling on that -- a rule with no
10101    /// PTY analogue, which is why PTY does not take this extra precondition.
10102    fn require_session_runtime_policy(
10103        &self,
10104        address: &SessionAddress,
10105        requirement: ProviderRuntimeRequirement,
10106    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10107        let runtime_policy = {
10108            let bindings = self
10109                .session_bindings
10110                .lock()
10111                .unwrap_or_else(|poisoned| poisoned.into_inner());
10112            let binding = bindings
10113                .get(&address.session.instance_id)
10114                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10115            if binding.workspace_id != address.workspace_id {
10116                return Err(failure(
10117                    NodeFailureCode::SessionWorkspaceMismatch,
10118                    "session belongs to another workspace",
10119                ));
10120            }
10121            if binding.generation != address.session.generation {
10122                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10123            }
10124            binding.runtime_policy
10125        };
10126        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10127            let snapshot = self.internal_session_snapshot(address)?;
10128            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10129                && snapshot.provider.lead_activity != ProviderActivity::Idle
10130            {
10131                return Err(failure(
10132                    NodeFailureCode::TurnInFlight,
10133                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10134                ));
10135            }
10136        }
10137        require_policy(runtime_policy, false, false, requirement)
10138            .map(|()| runtime_policy)
10139            .map_err(|_| failure(
10140                NodeFailureCode::UnsupportedCapability,
10141                "session runtime policy does not admit this semantic operation",
10142            ))
10143    }
10144
10145    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10146        let mut bindings = self
10147            .session_bindings
10148            .lock()
10149            .unwrap_or_else(|poisoned| poisoned.into_inner());
10150        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10151            binding.workspace_id == address.workspace_id
10152                && binding.generation == address.session.generation
10153        }) {
10154            let removed = bindings.remove(&address.session.instance_id);
10155            self.clear_terminal_frame_watermark(address);
10156            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10157            return removed;
10158        }
10159        None
10160    }
10161
10162    fn cleanup_session_owned_materialization(
10163        &self,
10164        address: &SessionAddress,
10165        binding: &SessionBinding,
10166    ) -> Result<(), NodeFailure> {
10167        let Some(id) = binding.materialization_id.as_ref() else {
10168            return Ok(());
10169        };
10170        let owner = self
10171            .materializations
10172            .lock()
10173            .unwrap_or_else(|poisoned| poisoned.into_inner())
10174            .get(id)
10175            .map(|record| record.owner().clone());
10176        match owner {
10177            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10178            Some(MaterializationOwner::Session {
10179                incarnation_id,
10180                instance_id,
10181                generation,
10182            }) if incarnation_id == self.incarnation_id
10183                && instance_id == address.session.instance_id
10184                && generation == address.session.generation =>
10185            {
10186                self.cleanup_materialization(id)
10187            }
10188            Some(MaterializationOwner::Session { .. }) => Err(failure(
10189                NodeFailureCode::BackendOperationFailed,
10190                "session environment ownership requires recovery",
10191            )),
10192        }
10193    }
10194
10195    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10196        let mut watermarks = self
10197            .terminal_frame_watermarks
10198            .lock()
10199            .unwrap_or_else(|poisoned| poisoned.into_inner());
10200        if watermarks
10201            .get(&address.session.instance_id)
10202            .is_some_and(|(current, _)| current == address)
10203        {
10204            watermarks.remove(&address.session.instance_id);
10205        }
10206    }
10207
10208    #[cfg(test)]
10209    fn address_for(
10210        &self,
10211        instance_id: AgentInstanceId,
10212        generation: SessionGeneration,
10213    ) -> Option<SessionAddress> {
10214        let binding = self.session_bindings
10215            .lock()
10216            .unwrap_or_else(|poisoned| poisoned.into_inner())
10217            .get(&instance_id)
10218            .cloned()?;
10219        (binding.generation == generation).then_some(SessionAddress {
10220            workspace_id: binding.workspace_id,
10221            session: SessionKey { instance_id, generation },
10222        })
10223    }
10224
10225    fn arm_resume(
10226        &self,
10227        address: &SessionAddress,
10228        command_id: CommandId,
10229        runtime_policy: ProviderRuntimePolicy,
10230    ) -> Result<(), NodeFailure> {
10231        let mut bindings = self
10232            .session_bindings
10233            .lock()
10234            .unwrap_or_else(|poisoned| poisoned.into_inner());
10235        let binding = bindings
10236            .get_mut(&address.session.instance_id)
10237            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10238        if binding.workspace_id != address.workspace_id {
10239            return Err(failure(
10240                NodeFailureCode::SessionWorkspaceMismatch,
10241                "session belongs to another workspace",
10242            ));
10243        }
10244        if binding.generation != address.session.generation {
10245            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10246        }
10247        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10248        Ok(())
10249    }
10250
10251    fn clear_armed_resume(&self, address: &SessionAddress) {
10252        let mut bindings = self
10253            .session_bindings
10254            .lock()
10255            .unwrap_or_else(|poisoned| poisoned.into_inner());
10256        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10257            if binding.workspace_id == address.workspace_id
10258                && binding.generation == address.session.generation
10259            {
10260                binding.pending_resume = None;
10261            }
10262        }
10263    }
10264
10265    fn publish_control(&self, event: ControlEvent) {
10266        let revokes_harness_mcp = matches!(
10267            &event.event,
10268            ControlEventKind::Exited { .. }
10269                | ControlEventKind::Failed { .. }
10270                | ControlEventKind::Removed
10271        );
10272        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10273            let mut bindings = self
10274                .session_bindings
10275                .lock()
10276                .unwrap_or_else(|poisoned| poisoned.into_inner());
10277            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10278                return;
10279            };
10280            let previous_address = SessionAddress {
10281                workspace_id: binding.workspace_id.clone(),
10282                session: SessionKey {
10283                    instance_id: event.instance_id,
10284                    generation: binding.generation,
10285                },
10286            };
10287            let mut resume_command_rejected = false;
10288            if binding.generation == event.generation {
10289                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10290                    let rejected_resume = matches!(
10291                        &event.event,
10292                        ControlEventKind::CommandRejected { .. }
10293                    ) && event.command_id == Some(pending_command_id);
10294                    let resume_failed = matches!(
10295                        &event.event,
10296                        ControlEventKind::ResumeDenied { .. }
10297                            | ControlEventKind::ResumeFailed { .. }
10298                    );
10299                    if pending_generation == binding.generation
10300                        && (rejected_resume || resume_failed)
10301                    {
10302                        resume_command_rejected = rejected_resume;
10303                        binding.pending_resume = None;
10304                    }
10305                }
10306            } else {
10307                let expected = binding.generation.0.checked_add(1);
10308                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10309                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10310                    generation == binding.generation
10311                })
10312                    || expected != Some(event.generation.0)
10313                    || !authorized
10314                {
10315                    return;
10316                }
10317                let (_, _, runtime_policy) = binding
10318                    .pending_resume
10319                    .expect("authorized resume retains its pending runtime policy");
10320                binding.generation = event.generation;
10321                binding.runtime_policy = runtime_policy;
10322                binding.pending_resume = None;
10323            }
10324            (
10325                SessionAddress {
10326                    workspace_id: binding.workspace_id.clone(),
10327                    session: SessionKey {
10328                        instance_id: event.instance_id,
10329                        generation: event.generation,
10330                    },
10331                },
10332                (previous_address.session.generation != event.generation)
10333                    .then_some(previous_address),
10334                binding.record_id.clone(),
10335                binding.runtime_policy.provider_session_identity,
10336                resume_command_rejected,
10337            )
10338        };
10339        if let Some(previous_address) = previous_address.as_ref() {
10340            self.clear_terminal_frame_watermark(previous_address);
10341        }
10342        if let Some(record_id) = record_id {
10343            self.reconcile_managed_record(
10344                &record_id,
10345                &address,
10346                &event,
10347                identity_admitted,
10348                resume_command_rejected,
10349            );
10350        }
10351        // Every state change a session's process lifecycle can reach gets a
10352        // line here: this is the single chokepoint every `ControlEventKind`
10353        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10354        // came from a fresh spawn or a resume. Per-message provider chatter
10355        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10356        // matched here -- that is exactly the per-frame noise this crate
10357        // family's level discipline forbids at INFO.
10358        match &event.event {
10359            ControlEventKind::Running { process_id } => {
10360                #[cfg(unix)]
10361                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10362                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10363                }
10364                tracing::info!(
10365                    node_id = %self.node_id,
10366                    workspace_id = %address.workspace_id,
10367                    session = ?address.session,
10368                    pid = ?process_id,
10369                    "session process is running",
10370                );
10371            }
10372            ControlEventKind::Exited { exit_code, forced } => {
10373                #[cfg(unix)]
10374                if let Some(proxy) = self.signer_proxy.get().cloned() {
10375                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10376                    if proxy.has_session(&key) {
10377                        // Revoke at core off the event path (blocking uplink call).
10378                        std::thread::spawn(move || proxy.release(&key));
10379                    }
10380                }
10381                tracing::info!(
10382                    node_id = %self.node_id,
10383                    workspace_id = %address.workspace_id,
10384                    session = ?address.session,
10385                    exit_code = ?exit_code,
10386                    forced = forced,
10387                    "session process exited",
10388                );
10389            }
10390            ControlEventKind::Failed { message } => {
10391                tracing::warn!(
10392                    node_id = %self.node_id,
10393                    workspace_id = %address.workspace_id,
10394                    session = ?address.session,
10395                    cause = %message,
10396                    "session failed",
10397                );
10398            }
10399            ControlEventKind::CommandRejected { message } => {
10400                tracing::warn!(
10401                    node_id = %self.node_id,
10402                    workspace_id = %address.workspace_id,
10403                    session = ?address.session,
10404                    cause = %message,
10405                    "session command rejected",
10406                );
10407            }
10408            ControlEventKind::Removed => {
10409                tracing::info!(
10410                    node_id = %self.node_id,
10411                    workspace_id = %address.workspace_id,
10412                    session = ?address.session,
10413                    "session removed",
10414                );
10415            }
10416            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10417                tracing::info!(
10418                    node_id = %self.node_id,
10419                    workspace_id = %address.workspace_id,
10420                    session = ?address.session,
10421                    provider_session = ?provider_session,
10422                    "session resume authorized",
10423                );
10424            }
10425            ControlEventKind::Resumed { session: provider_session, process_id } => {
10426                tracing::info!(
10427                    node_id = %self.node_id,
10428                    workspace_id = %address.workspace_id,
10429                    session = ?address.session,
10430                    provider_session = ?provider_session,
10431                    pid = ?process_id,
10432                    "session resumed",
10433                );
10434            }
10435            ControlEventKind::ResumeDenied { reason } => {
10436                tracing::warn!(
10437                    node_id = %self.node_id,
10438                    workspace_id = %address.workspace_id,
10439                    session = ?address.session,
10440                    cause = %reason,
10441                    "session resume denied",
10442                );
10443            }
10444            ControlEventKind::ResumeFailed { message } => {
10445                tracing::warn!(
10446                    node_id = %self.node_id,
10447                    workspace_id = %address.workspace_id,
10448                    session = ?address.session,
10449                    cause = %message,
10450                    "session resume failed",
10451                );
10452            }
10453            _ => {}
10454        }
10455        self.apply_pending_acp_options(&address, &event);
10456        let stream_chunk = agent_stream_chunk(&event);
10457        self.publish(NodeEvent::Control {
10458            address: address.clone(),
10459            event,
10460        });
10461        if let Some(chunk) = stream_chunk {
10462            self.publish_agent_stream_chunk(address.clone(), chunk);
10463        }
10464        if revokes_harness_mcp {
10465            SessionLifecycleHook::on_session_ended(self, &address);
10466        }
10467    }
10468
10469    fn reconcile_managed_record(
10470        &self,
10471        record_id: &SessionRecordId,
10472        address: &SessionAddress,
10473        event: &ControlEvent,
10474        identity_admitted: bool,
10475        resume_command_rejected: bool,
10476    ) {
10477        let _transaction = self
10478            .state_transaction
10479            .lock()
10480            .unwrap_or_else(|poisoned| poisoned.into_inner());
10481        let observed_identity = match &event.event {
10482            ControlEventKind::ProviderEvent {
10483                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10484                ..
10485            } if identity_admitted => Some(identity.clone()),
10486            _ => None,
10487        };
10488        let identity_was_observed = observed_identity.is_some();
10489        let replacement_id = observed_identity
10490            .as_ref()
10491            .and_then(|_| self.allocate_record_id().ok());
10492        let mut upserts = Vec::new();
10493        let mut removals = Vec::new();
10494        let mut rebind = None;
10495        let original_records;
10496        let original_materializations = self
10497            .materializations
10498            .lock()
10499            .unwrap_or_else(|poisoned| poisoned.into_inner())
10500            .clone();
10501        {
10502            let mut records = self
10503                .session_records
10504                .lock()
10505                .unwrap_or_else(|poisoned| poisoned.into_inner());
10506            original_records = records.records.clone();
10507            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10508                return;
10509            };
10510            if let Some(identity) = observed_identity {
10511                let existing_id = records
10512                    .records
10513                    .values()
10514                    .find(|record| {
10515                        record.record_id != *record_id
10516                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10517                                session_registry::same_provider_session(
10518                                    &record.provider,
10519                                    record_identity,
10520                                    &current_snapshot.provider,
10521                                    &identity,
10522                                )
10523                            })
10524                    })
10525                    .map(|record| record.record_id.clone());
10526                if let Some(existing_id) = existing_id {
10527                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10528                        record.mode == current_snapshot.mode
10529                            && record.workspace_id == current_snapshot.workspace_id
10530                            && record.environment_profile == current_snapshot.environment_profile
10531                            && record.bundle == current_snapshot.bundle
10532                            && record.context_id == current_snapshot.context_id
10533                            && record.context == current_snapshot.context
10534                            && record
10535                                .canonical_root
10536                                .as_utf8()
10537                                .zip(current_snapshot.canonical_root.as_utf8())
10538                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10539                    });
10540                    if !scope_matches {
10541                        if let Some(current) = records.records.get_mut(record_id) {
10542                            current.state = ManagedSessionState::Unavailable;
10543                            current.last_error =
10544                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10545                            current.updated_at_unix_ms = unix_time_ms();
10546                            upserts.push(current.clone());
10547                        }
10548                    } else {
10549                        let existing_is_active = records
10550                            .records
10551                            .get(&existing_id)
10552                            .is_some_and(|record| record.active_session.is_some());
10553                        if existing_is_active {
10554                            if let Some(current) = records.records.get_mut(record_id) {
10555                                current.state = ManagedSessionState::Unavailable;
10556                                current.last_error =
10557                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10558                                current.updated_at_unix_ms = unix_time_ms();
10559                                upserts.push(current.clone());
10560                            }
10561                        } else {
10562                            if current_snapshot.provider_session.is_some() {
10563                                if let Some(current) = records.records.get_mut(record_id) {
10564                                    current.active_session = None;
10565                                    current.state = ManagedSessionState::Dormant;
10566                                    current.updated_at_unix_ms = unix_time_ms();
10567                                    upserts.push(current.clone());
10568                                }
10569                            } else {
10570                                records.records.remove(record_id);
10571                                removals.push(record_id.clone());
10572                            }
10573                            if let Some(existing) = records.records.get_mut(&existing_id) {
10574                                existing.provider_session = Some(identity.clone());
10575                                existing.active_session = Some(address.clone());
10576                                existing.state = ManagedSessionState::Live;
10577                                existing.updated_at_unix_ms = unix_time_ms();
10578                                existing.last_error = None;
10579                                upserts.push(existing.clone());
10580                            }
10581                            rebind = Some(existing_id);
10582                        }
10583                    }
10584                } else if current_snapshot
10585                    .provider_session
10586                    .as_ref()
10587                    .is_some_and(|current| {
10588                        !session_registry::same_provider_session(
10589                            &current_snapshot.provider,
10590                            current,
10591                            &current_snapshot.provider,
10592                            &identity,
10593                        )
10594                    })
10595                {
10596                    if let Some(current) = records.records.get_mut(record_id) {
10597                        current.active_session = None;
10598                        if current.bundle.is_some() || current.context.is_some() {
10599                            let had_bundle = current.bundle.is_some();
10600                            current.provider_session = None;
10601                            current.bundle = None;
10602                            current.context_id = None;
10603                            current.context = None;
10604                            current.state = ManagedSessionState::Unavailable;
10605                            current.last_error = Some(if had_bundle {
10606                                "bundle-unavailable".to_owned()
10607                            } else {
10608                                "context-unavailable".to_owned()
10609                            });
10610                        } else {
10611                            current.state = ManagedSessionState::Dormant;
10612                        }
10613                        current.updated_at_unix_ms = unix_time_ms();
10614                        upserts.push(current.clone());
10615                    }
10616                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10617                        if let Some(current) = records.records.get_mut(record_id) {
10618                            current.state = ManagedSessionState::Unavailable;
10619                            current.last_error =
10620                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10621                            current.updated_at_unix_ms = unix_time_ms();
10622                            upserts.push(current.clone());
10623                        }
10624                    } else if let Some(new_record_id) = replacement_id {
10625                        let now = unix_time_ms();
10626                        let replacement = ManagedSessionRecord {
10627                            record_id: new_record_id.clone(),
10628                            display_name: format!(
10629                                "{} #{}",
10630                                current_snapshot.provider.as_str(),
10631                                records
10632                                    .records
10633                                    .values()
10634                                    .filter(|record| {
10635                                        record.provider == current_snapshot.provider
10636                                    })
10637                                    .count()
10638                                    .saturating_add(1),
10639                            ),
10640                            provider: current_snapshot.provider.clone(),
10641                            mode: current_snapshot.mode,
10642                            state: ManagedSessionState::Live,
10643                            workspace_id: current_snapshot.workspace_id,
10644                            canonical_root: current_snapshot.canonical_root,
10645                            provider_session: Some(identity),
10646                            active_session: Some(address.clone()),
10647                            environment_profile: current_snapshot.environment_profile,
10648                            bundle: current_snapshot.bundle,
10649                            context_id: current_snapshot.context_id,
10650                            context: current_snapshot.context,
10651                            // This is a fresh record_id representing the session under its
10652                            // newly-observed identity; it has not yet produced any clean
10653                            // exit of its own, so it starts with no exported pack (the old
10654                            // record, still addressable under its own record_id, keeps
10655                            // whatever exported_context it already had).
10656                            exported_context: None,
10657                            task_binding: current_snapshot.task_binding,
10658                            created_at_unix_ms: now,
10659                            updated_at_unix_ms: now,
10660                            last_error: None,
10661                        };
10662                        records
10663                            .records
10664                            .insert(new_record_id.clone(), replacement.clone());
10665                        upserts.push(replacement);
10666                        rebind = Some(new_record_id);
10667                    } else if let Some(current) = records.records.get_mut(record_id) {
10668                        current.state = ManagedSessionState::Unavailable;
10669                        current.last_error =
10670                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10671                        upserts.push(current.clone());
10672                    }
10673                } else if let Some(current) = records.records.get_mut(record_id) {
10674                    current.provider_session = Some(identity);
10675                    current.active_session = Some(address.clone());
10676                    current.state = ManagedSessionState::Live;
10677                    current.updated_at_unix_ms = unix_time_ms();
10678                    current.last_error = None;
10679                    upserts.push(current.clone());
10680                }
10681            } else if let Some(current) = records.records.get_mut(record_id) {
10682                if resume_command_rejected {
10683                    current.active_session = None;
10684                    current.state = detached_record_state(current);
10685                    current.updated_at_unix_ms = unix_time_ms();
10686                    current.last_error =
10687                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10688                    upserts.push(current.clone());
10689                } else {
10690                    match &event.event {
10691                    ControlEventKind::Running { .. }
10692                    | ControlEventKind::ResumeAuthorized { .. }
10693                    | ControlEventKind::Resumed { .. } => {
10694                        current.active_session = Some(address.clone());
10695                        current.state = active_record_state(
10696                            identity_admitted,
10697                            current.provider_session.is_some(),
10698                        );
10699                        current.updated_at_unix_ms = unix_time_ms();
10700                        current.last_error = None;
10701                        upserts.push(current.clone());
10702                    }
10703                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10704                        current.active_session = None;
10705                        current.state = detached_record_state(current);
10706                        current.updated_at_unix_ms = unix_time_ms();
10707                        upserts.push(current.clone());
10708                    }
10709                    ControlEventKind::Failed { message }
10710                    | ControlEventKind::ResumeFailed { message } => {
10711                        current.active_session = None;
10712                        current.state = detached_record_state(current);
10713                        current.updated_at_unix_ms = unix_time_ms();
10714                        current.last_error = Some(
10715                            session_registry::sanitized_record_error_summary(message),
10716                        );
10717                        upserts.push(current.clone());
10718                    }
10719                    ControlEventKind::ResumeDenied { reason } => {
10720                        current.active_session = None;
10721                        current.state = detached_record_state(current);
10722                        current.updated_at_unix_ms = unix_time_ms();
10723                        current.last_error = Some(
10724                            session_registry::sanitized_record_error_summary(reason),
10725                        );
10726                        upserts.push(current.clone());
10727                    }
10728                        _ => {}
10729                    }
10730                }
10731            }
10732        }
10733        let materialization_owner_record = identity_was_observed.then(|| {
10734            rebind.clone().unwrap_or_else(|| record_id.clone())
10735        }).and_then(|candidate| {
10736            self.session_records
10737                .lock()
10738                .unwrap_or_else(|poisoned| poisoned.into_inner())
10739                .records
10740                .get(&candidate)
10741                .filter(|record| {
10742                    record.provider_session.is_some()
10743                        && record.active_session.as_ref() == Some(address)
10744                })
10745                .map(|_| candidate)
10746        });
10747        if let Some(owner_record_id) = materialization_owner_record {
10748            let materialization_id = self
10749                .session_bindings
10750                .lock()
10751                .unwrap_or_else(|poisoned| poisoned.into_inner())
10752                .get(&address.session.instance_id)
10753                .and_then(|binding| binding.materialization_id.clone());
10754            if let Some(materialization_id) = materialization_id {
10755                let mut transfer_failed = false;
10756                {
10757                    let mut materializations = self
10758                        .materializations
10759                        .lock()
10760                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10761                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10762                        match ownership.owner() {
10763                            MaterializationOwner::Session {
10764                                incarnation_id,
10765                                instance_id,
10766                                generation,
10767                            } if *incarnation_id == self.incarnation_id
10768                                && *instance_id == address.session.instance_id
10769                                && *generation == address.session.generation =>
10770                            {
10771                                transfer_failed = ownership
10772                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10773                                    .is_err();
10774                            }
10775                            MaterializationOwner::Record { record_id }
10776                                if record_id == &owner_record_id => {}
10777                            MaterializationOwner::Record { record_id } => {
10778                                let expected = record_id.clone();
10779                                transfer_failed = ownership
10780                                    .transfer_record_owner(
10781                                        &expected,
10782                                        owner_record_id.clone(),
10783                                        unix_time_ms(),
10784                                    )
10785                                    .is_err();
10786                            }
10787                            _ => transfer_failed = true,
10788                        }
10789                        if transfer_failed {
10790                            let _ = ownership.mark_recovery_required(unix_time_ms());
10791                        }
10792                    } else {
10793                        transfer_failed = true;
10794                    }
10795                }
10796                if transfer_failed {
10797                    if let Some(record) = self
10798                        .session_records
10799                        .lock()
10800                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10801                        .records
10802                        .get_mut(&owner_record_id)
10803                    {
10804                        record.state = ManagedSessionState::Unavailable;
10805                        record.last_error = Some("environment-profile-unavailable".to_owned());
10806                        record.updated_at_unix_ms = unix_time_ms();
10807                        upserts.push(record.clone());
10808                    }
10809                }
10810            }
10811        }
10812        if let Some(new_record_id) = rebind.clone() {
10813            if let Some(binding) = self
10814                .session_bindings
10815                .lock()
10816                .unwrap_or_else(|poisoned| poisoned.into_inner())
10817                .get_mut(&address.session.instance_id)
10818            {
10819                binding.record_id = Some(new_record_id);
10820            }
10821        }
10822        if upserts.is_empty() && removals.is_empty() {
10823            return;
10824        }
10825        if self.persist_state_locked().is_err() {
10826            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10827            let mut records = self
10828                .session_records
10829                .lock()
10830                .unwrap_or_else(|poisoned| poisoned.into_inner());
10831            records.records = original_records;
10832            *self
10833                .materializations
10834                .lock()
10835                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10836            if let Some(current) = records.records.get_mut(record_id) {
10837                current.state = ManagedSessionState::Unavailable;
10838                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10839                current.updated_at_unix_ms = unix_time_ms();
10840                upserts = vec![current.clone()];
10841            } else {
10842                upserts.clear();
10843            }
10844            removals.clear();
10845            drop(records);
10846            if let Some(binding) = self
10847                .session_bindings
10848                .lock()
10849                .unwrap_or_else(|poisoned| poisoned.into_inner())
10850                .get_mut(&address.session.instance_id)
10851            {
10852                binding.record_id = Some(record_id.clone());
10853            }
10854            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10855        }
10856        for record in upserts {
10857            self.publish_record(record);
10858        }
10859        for record_id in removals {
10860            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10861        }
10862    }
10863
10864    fn snapshot(&self) -> NodeSnapshot {
10865        let control = self.handle.snapshot();
10866        let bindings = self
10867            .session_bindings
10868            .lock()
10869            .unwrap_or_else(|poisoned| poisoned.into_inner())
10870            .clone();
10871        let workspace_roots = self
10872            .workspaces
10873            .read()
10874            .unwrap_or_else(|poisoned| poisoned.into_inner())
10875            .clone();
10876        let environment_profiles = self
10877            .environment_profiles
10878            .read()
10879            .unwrap_or_else(|poisoned| poisoned.into_inner());
10880        let mut workspaces = workspace_roots
10881            .iter()
10882            .map(|(workspace_id, canonical_root)| {
10883                (
10884                    workspace_id.clone(),
10885                    WorkspaceSnapshot {
10886                        workspace_id: workspace_id.clone(),
10887                        canonical_root: opaque_windows_path(canonical_root.clone()),
10888                        sessions: Vec::new(),
10889                        worktree_service_mode: Some(
10890                            self.worktree_service_modes
10891                                .get(workspace_id)
10892                                .copied()
10893                                .unwrap_or(WorktreeServiceMode::Manual),
10894                        ),
10895                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10896                            profiles: self
10897                                .managed_worktree_profiles
10898                                .get(workspace_id)
10899                                .into_iter()
10900                                .flat_map(BTreeMap::values)
10901                                .map(|profile| ManagedWorktreeProfileSummary {
10902                                    id: profile.profile_id().clone(),
10903                                    revision: profile.revision().clone(),
10904                                    retention: profile.retention(),
10905                                })
10906                                .collect(),
10907                        }),
10908                    },
10909                )
10910            })
10911            .collect::<BTreeMap<_, _>>();
10912        let mut agent_progress = Vec::new();
10913        for session in &control.sessions {
10914            let Some(binding) = bindings.get(&session.instance_id) else {
10915                continue;
10916            };
10917            if binding.generation != session.generation {
10918                continue;
10919            }
10920            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10921                let address = SessionAddress {
10922                    workspace_id: binding.workspace_id.clone(),
10923                    session: SessionKey {
10924                        instance_id: session.instance_id,
10925                        generation: session.generation,
10926                    },
10927                };
10928                if let Some(progress) =
10929                    agent_progress_from_provider_snapshot(address, &session.provider)
10930                {
10931                    agent_progress.push(progress);
10932                }
10933                workspace.sessions.push(session.clone());
10934            }
10935        }
10936        NodeSnapshot {
10937            node_id: self.node_id.clone(),
10938            enabled_providers: self.enabled_providers.clone(),
10939            provider_runtime_statuses: {
10940                let updates = self
10941                    .provider_runtime_status_updates
10942                    .read()
10943                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10944                ProviderRuntimeStatuses::new(
10945                    self.provider_runtime_statuses
10946                        .iter()
10947                        .filter(|status| !updates.contains_key(status.provider()))
10948                        .cloned()
10949                        .chain(updates.values().cloned()),
10950                )
10951                .expect("refreshed provider runtime inventory remains bounded and unique")
10952            },
10953            workspaces: workspaces.into_values().collect(),
10954            session_records: self
10955                .session_records
10956                .lock()
10957                .unwrap_or_else(|poisoned| poisoned.into_inner())
10958                .records
10959                .values()
10960                .cloned()
10961                .collect(),
10962            managed_worktrees: self.managed_worktrees
10963                .lock()
10964                .unwrap_or_else(|poisoned| poisoned.into_inner())
10965                .snapshots(),
10966            launch_inventory: Some(LaunchInventory {
10967                spawn_profiles: Some(
10968                    self.spawn_profiles
10969                        .iter()
10970                        .filter_map(|profile| {
10971                            resolved_spawn_profile_summary(profile, &environment_profiles)
10972                        })
10973                        .collect(),
10974                ),
10975                bundles: Some(
10976                    self.bundle_catalog
10977                        .read()
10978                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10979                        .iter()
10980                        .map(NodeBundle::receipt)
10981                        .collect(),
10982                ),
10983                // Opaque station allowlist ids from empty-default catalog /
10984                // optional startup loader. Never secrets. Dig2 probe stays stub.
10985                network_allowlists: {
10986                    let catalog = self
10987                        .network_allowlist_catalog
10988                        .read()
10989                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10990                    if catalog.is_empty() {
10991                        None
10992                    } else {
10993                        Some(catalog.ids().cloned().collect())
10994                    }
10995                },
10996            }),
10997            agent_progress,
10998        }
10999    }
11000
11001    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11002        assert!(
11003            !matches!(
11004                &event,
11005                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11006            ),
11007            "terminal frame and agent stream events must use their own dedicated live channel",
11008        );
11009        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11010        let sequence = history
11011            .last_sequence
11012            .checked_add(1)
11013            .expect("node event sequence exhausted");
11014        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11015            if let Some(evicted) = history.events.pop_front() {
11016                history.replay_floor_sequence = evicted
11017                    .sequence
11018                    .checked_add(1)
11019                    .expect("node replay floor sequence exhausted");
11020                history.removed_record_providers.remove(&evicted.sequence);
11021            }
11022        }
11023        match &event {
11024            NodeEvent::HarnessMcpReadCall { .. } => {
11025                panic!("transient harness MCP calls must not enter durable event history")
11026            }
11027            NodeEvent::SessionRecordUpserted { record } => {
11028                history
11029                    .record_providers
11030                    .insert(record.record_id.clone(), record.provider.clone());
11031            }
11032            NodeEvent::SessionRecordRemoved { record_id } => {
11033                if let Some(provider) = history.record_providers.remove(record_id) {
11034                    history.removed_record_providers.insert(sequence, provider);
11035                }
11036            }
11037            NodeEvent::ManagedWorktreeUpserted { .. }
11038            | NodeEvent::ManagedWorktreeRemoved { .. }
11039            | NodeEvent::Control { .. }
11040            | NodeEvent::ControllerChanged { .. }
11041            | NodeEvent::SessionRecordHistorySummarized { .. }
11042            | NodeEvent::WorkspaceAdded { .. }
11043            | NodeEvent::WorkspaceRemoved { .. }
11044            | NodeEvent::ResyncRequired { .. }
11045            | NodeEvent::AgentStream { .. }
11046            | NodeEvent::TerminalFrame { .. } => {}
11047        }
11048        let envelope = NodeEventEnvelope { sequence, event };
11049        history.events.push_back(envelope.clone());
11050        history.last_sequence = sequence;
11051        let _ = self.event_tx.send(envelope.clone());
11052        drop(history);
11053        envelope
11054    }
11055
11056    /// Publishes a transient (never durable) `NodeEvent` -- today only
11057    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11058    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11059    /// why this must never ride the shared `event_tx` bus). Returns whether
11060    /// the send reached at least one subscriber: `broadcast::Sender::send`
11061    /// fails in exactly one way, no active receiver at that instant, and
11062    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11063    /// return to fail the pending call immediately rather than leave it to
11064    /// expire silently at `LOCAL_READ_TIMEOUT`/
11065    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11066    fn publish_transient(&self, event: NodeEvent) -> bool {
11067        assert!(event.requires_harness_mcp_proxy_capability());
11068        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11069        let envelope = NodeEventEnvelope { sequence: 0, event };
11070        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11071            Ok(_) => true,
11072            Err(broadcast::error::SendError(undelivered)) => {
11073                if let Some(NodeEventEnvelope {
11074                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11075                    ..
11076                }) = undelivered.first() {
11077                    // `request.content_type` only, never a per-tool label --
11078                    // this crate does not decode `request.body` and does not
11079                    // hold `hatchery-harness-api` (Nested Control Plane
11080                    // doctrine, Law 3; see this crate's dependency on
11081                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11082                    tracing::warn!(
11083                        call_id = call_id.as_str(),
11084                        content_type = ?request.content_type,
11085                        cause = "no-subscribers",
11086                        "harness MCP proxy event could not be published",
11087                    );
11088                }
11089                false
11090            }
11091        }
11092    }
11093
11094    fn publish_terminal_frames(&self) {
11095        let snapshot = self.handle.snapshot();
11096        let bindings = self
11097            .session_bindings
11098            .lock()
11099            .unwrap_or_else(|poisoned| poisoned.into_inner());
11100        let candidates = snapshot
11101            .sessions
11102            .iter()
11103            .filter_map(|session| {
11104                let binding = bindings.get(&session.instance_id)?;
11105                if binding.generation != session.generation {
11106                    return None;
11107                }
11108                let frame = session.terminal_frame.clone()?;
11109                crate::session_signals::feed(
11110                    binding.workspace_id.as_str(),
11111                    session.instance_id.0,
11112                    session.agent_id.as_str(),
11113                    &frame.contents,
11114                );
11115                Some((
11116                    SessionAddress {
11117                        workspace_id: binding.workspace_id.clone(),
11118                        session: SessionKey {
11119                            instance_id: session.instance_id,
11120                            generation: session.generation,
11121                        },
11122                    },
11123                    frame,
11124                ))
11125            })
11126            .collect();
11127        drop(bindings);
11128        self.publish_terminal_frame_candidates(candidates);
11129    }
11130
11131    fn publish_terminal_frame_candidates(
11132        &self,
11133        candidates: Vec<(SessionAddress, TerminalFrame)>,
11134    ) {
11135        let mut watermarks = self
11136            .terminal_frame_watermarks
11137            .lock()
11138            .unwrap_or_else(|poisoned| poisoned.into_inner());
11139        let mut events = Vec::with_capacity(candidates.len());
11140        for (address, frame) in candidates {
11141            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11142            let advanced = watermarks
11143                .get(&address.session.instance_id)
11144                .map_or(true, |(current_address, sequence)| {
11145                    current_address != &address || frame.sequence > *sequence
11146                });
11147            if !advanced {
11148                continue;
11149            }
11150            if is_first_frame {
11151                // Do NOT sessionize on first PTY output: a session is a
11152                // provider conversation id, not bytes on the wire. Drafts
11153                // leave the provisional map only when HQ attaches
11154                // (Resize / TerminalBytes). The attach-never-arrived timer
11155                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11156                // disconnect grace starts only on a real client WS close.
11157                // This is the first PTY output this node has observed for
11158                // this session -- a one-time event per spawn, not per-frame
11159                // chatter (the watermark makes sure of that). A session that
11160                // reaches this line with no controller attached anywhere on
11161                // the node is running unobserved: exactly how processes from
11162                // repeated spawn attempts went orphaned for hours without a
11163                // single line about it.
11164                if self.controller_state().is_none() {
11165                    tracing::warn!(
11166                        node_id = %self.node_id,
11167                        workspace_id = %address.workspace_id,
11168                        session = ?address.session,
11169                        terminal_size = ?frame.size,
11170                        "session producing output with no controller attached",
11171                    );
11172                } else {
11173                    tracing::info!(
11174                        node_id = %self.node_id,
11175                        workspace_id = %address.workspace_id,
11176                        session = ?address.session,
11177                        terminal_size = ?frame.size,
11178                        "session produced its first output",
11179                    );
11180                }
11181            }
11182            watermarks.insert(
11183                address.session.instance_id,
11184                (address.clone(), frame.sequence),
11185            );
11186            if let Some((at, reported)) = self
11187                .pty_input_marks
11188                .lock()
11189                .unwrap_or_else(|poisoned| poisoned.into_inner())
11190                .get_mut(&address.session.instance_id)
11191            {
11192                // First frame after an input: PTY write -> output published.
11193                if !*reported && at.elapsed() < Duration::from_secs(5) {
11194                    *reported = true;
11195                    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");
11196                }
11197            }
11198            events.push(NodeEvent::TerminalFrame { address, frame });
11199        }
11200        drop(watermarks);
11201        if events.is_empty() {
11202            return;
11203        }
11204        let mut history = self
11205            .history
11206            .lock()
11207            .unwrap_or_else(|poisoned| poisoned.into_inner());
11208        let mut envelopes = Vec::with_capacity(events.len());
11209        for event in events {
11210            let sequence = history
11211                .last_sequence
11212                .checked_add(1)
11213                .expect("node event sequence exhausted");
11214            history.last_sequence = sequence;
11215            envelopes.push(NodeEventEnvelope { sequence, event });
11216        }
11217        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11218        drop(history);
11219    }
11220
11221    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11222    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11223    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11224    /// counter every other event kind shares, never a counter of its own --
11225    /// `queue_connection_event`/`queue_connection_event_batch` compare
11226    /// `envelope.sequence` against one `discard_events_through` watermark
11227    /// regardless of which broadcast channel it arrived on, so a
11228    /// per-channel counter would desynchronize that comparison.
11229    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11230        let mut history = self
11231            .history
11232            .lock()
11233            .unwrap_or_else(|poisoned| poisoned.into_inner());
11234        let sequence = history
11235            .last_sequence
11236            .checked_add(1)
11237            .expect("node event sequence exhausted");
11238        history.last_sequence = sequence;
11239        drop(history);
11240        // Debug, not info/warn -- this fires on every single chunk, but it
11241        // is exactly the counter the next drop-measurement pass needs: what
11242        // the node actually emitted (`source_sequence`, `kind`) versus what
11243        // a harness subscriber's own forwarder later logs having received
11244        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11245        // event"), so a gap between the two logs names itself instead of
11246        // needing a new counter added ad hoc.
11247        tracing::debug!(
11248            source_sequence = chunk.source_sequence,
11249            kind = agent_stream_chunk_kind_label(&chunk.kind),
11250            envelope_sequence = sequence,
11251            "publishing agent stream chunk",
11252        );
11253        let envelope = NodeEventEnvelope {
11254            sequence,
11255            event: NodeEvent::AgentStream { address, chunk },
11256        };
11257        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11258    }
11259
11260    fn current_sequence(&self) -> u64 {
11261        self.history
11262            .lock()
11263            .unwrap_or_else(|poisoned| poisoned.into_inner())
11264            .last_sequence
11265    }
11266
11267    fn controller_state(&self) -> Option<ControllerState> {
11268        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11269        let lease = controller.as_ref().copied()?;
11270        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11271        if remaining.is_zero() {
11272            *controller = None;
11273            return None;
11274        }
11275        Some(ControllerState {
11276            connection_id: lease.connection_id,
11277            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11278        })
11279    }
11280
11281    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11282        if role != ClientRole::Operator {
11283            tracing::warn!(
11284                node_id = %self.node_id,
11285                connection_id,
11286                "controller lease rejected: observer connections cannot acquire control",
11287            );
11288            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11289        }
11290        let lease_ms = if lease_ms == 0 {
11291            DEFAULT_CONTROLLER_LEASE_MS
11292        } else {
11293            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11294        };
11295        let now = Instant::now();
11296        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11297        if let Some(active) = controller.as_ref() {
11298            if active.connection_id != connection_id && active.expires_at > now {
11299                tracing::warn!(
11300                    node_id = %self.node_id,
11301                    connection_id,
11302                    holder_connection_id = active.connection_id,
11303                    "controller lease rejected: another operator holds it",
11304                );
11305                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11306            }
11307        }
11308        // A reconnect (this connection already holds the lease, renewing
11309        // before expiry) happens on a periodic cadence; logging it at INFO
11310        // every renewal would be exactly the heartbeat chatter this crate
11311        // family's level discipline forbids. A genuine attach -- the
11312        // controller identity actually changing -- is a state change and
11313        // gets INFO; a same-connection renewal gets DEBUG.
11314        let is_new_attach = match controller.as_ref() {
11315            Some(active) => active.connection_id != connection_id,
11316            None => true,
11317        };
11318        *controller = Some(ControllerLease {
11319            connection_id,
11320            expires_at: now + Duration::from_millis(lease_ms),
11321        });
11322        drop(controller);
11323        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11324        if is_new_attach {
11325            tracing::info!(
11326                node_id = %self.node_id,
11327                connection_id,
11328                lease_ms,
11329                "controller lease acquired",
11330            );
11331        } else {
11332            tracing::debug!(
11333                node_id = %self.node_id,
11334                connection_id,
11335                lease_ms,
11336                "controller lease renewed",
11337            );
11338        }
11339        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11340        Ok(state)
11341    }
11342
11343    fn release_controller(&self, connection_id: u64) -> bool {
11344        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11345        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11346        if released {
11347            *controller = None;
11348        }
11349        drop(controller);
11350        if released {
11351            tracing::info!(
11352                node_id = %self.node_id,
11353                connection_id,
11354                "controller lease released",
11355            );
11356            self.publish(NodeEvent::ControllerChanged { controller: None });
11357        }
11358        released
11359    }
11360
11361    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11362    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11363    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11364    /// provider session; it only cancels the orphan timer. Disconnect/reload
11365    /// draft kills are owned by HQ and arm only on a real client WS close.
11366    /// ACP path of launch options: once the agent advertises its config options, write the
11367    /// pending model / effort through session/set_config_option (session/set_model when the
11368    /// agent has no model option). Gaps are logged, never forced.
11369    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11370        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11371            return;
11372        };
11373        let id = address.session.instance_id;
11374        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11375            return;
11376        };
11377        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11378            .iter()
11379            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11380            .collect();
11381        let (writes, set_model, effort_gap) =
11382            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11383        if set_model {
11384            if let Some(model_id) = model.clone() {
11385                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11386                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11387                }
11388            }
11389        }
11390        for (option_id, value_json) in writes {
11391            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11392                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11393            }
11394        }
11395        if effort_gap {
11396            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11397        }
11398        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11399    }
11400
11401    fn mark_spawn_provisional(&self, address: SessionAddress) {
11402        let id = address.session.instance_id;
11403        self.provisional_spawns
11404            .lock()
11405            .unwrap_or_else(|poisoned| poisoned.into_inner())
11406            .insert(id, (address, Instant::now()));
11407        tracing::info!(
11408            node_id = %self.node_id,
11409            instance_id = id.0,
11410            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11411            "draft spawn; waiting for HQ view",
11412        );
11413    }
11414
11415    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11416        let removed = self
11417            .provisional_spawns
11418            .lock()
11419            .unwrap_or_else(|poisoned| poisoned.into_inner())
11420            .remove(&instance_id);
11421        if removed.is_some() {
11422            tracing::info!(
11423                node_id = %self.node_id,
11424                instance_id = instance_id.0,
11425                "draft spawn claimed by HQ view",
11426            );
11427        }
11428    }
11429
11430    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11431        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11432        let mut map = self
11433            .provisional_spawns
11434            .lock()
11435            .unwrap_or_else(|poisoned| poisoned.into_inner());
11436        let due: Vec<AgentInstanceId> = map
11437            .iter()
11438            .filter(|(_, (_, at))| at.elapsed() >= grace)
11439            .map(|(id, _)| *id)
11440            .collect();
11441        due.into_iter()
11442            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11443            .collect()
11444    }
11445
11446    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11447        let instance_id = address.session.instance_id;
11448        tracing::warn!(
11449            node_id = %self.node_id,
11450            instance_id = instance_id.0,
11451            workspace = %address.workspace_id,
11452            reason = "attach_never_arrived",
11453            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11454            "draft spawn never viewed; stopping PTY",
11455        );
11456        let _ = self.dispatch(ControlCommand::Stop {
11457            instance_id,
11458            force: true,
11459        });
11460        let _ = self.remove_session(&address).await;
11461    }
11462
11463    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11464        if role != ClientRole::Operator {
11465            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11466        }
11467        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11468        let now = Instant::now();
11469        match controller.as_ref().copied() {
11470            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11471            Some(active) if active.expires_at <= now => {
11472                *controller = None;
11473                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11474            }
11475            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11476        }
11477    }
11478
11479    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11480        if !self
11481            .workspaces
11482            .read()
11483            .unwrap_or_else(|poisoned| poisoned.into_inner())
11484            .contains_key(&address.workspace_id)
11485        {
11486            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11487        }
11488        let binding = self
11489            .session_bindings
11490            .lock()
11491            .unwrap_or_else(|poisoned| poisoned.into_inner())
11492            .get(&address.session.instance_id)
11493            .cloned()
11494            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11495        if binding.workspace_id != address.workspace_id {
11496            return Err(failure(
11497                NodeFailureCode::SessionWorkspaceMismatch,
11498                "session belongs to another workspace",
11499            ));
11500        }
11501        if binding.generation != address.session.generation {
11502            return Err(failure(
11503                NodeFailureCode::StaleGeneration,
11504                &format!(
11505                    "stale session generation: expected {}, received {}",
11506                    binding.generation.0,
11507                    address.session.generation.0,
11508                ),
11509            ));
11510        }
11511        let snapshot = self.handle.snapshot();
11512        let Some(current) = snapshot
11513            .sessions
11514            .iter()
11515            .find(|item| item.instance_id == address.session.instance_id)
11516        else {
11517            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11518        };
11519        if current.generation != address.session.generation {
11520            return Err(failure(
11521                NodeFailureCode::StaleGeneration,
11522                &format!(
11523                    "stale session generation: expected {}, received {}",
11524                    current.generation.0,
11525                    address.session.generation.0,
11526                ),
11527            ));
11528        }
11529        Ok(current.agent_id.clone())
11530    }
11531
11532    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11533        self.dispatch_envelope(self.prepare_command(command))
11534    }
11535
11536    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11537        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11538        CommandEnvelope {
11539            id: CommandId(id),
11540            command,
11541        }
11542    }
11543
11544    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11545        let command_id = envelope.id;
11546        self.handle
11547            .dispatch(envelope)
11548            .map_err(|error| match error {
11549                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11550                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11551            })?;
11552        Ok(command_id)
11553    }
11554
11555    /// Records the kernel's own reason for asynchronously refusing
11556    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11557    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11558    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11559    /// first, and if the map is still at capacity after that, the single
11560    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11561    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11562    /// is always the oldest.
11563    fn record_rejected_command(
11564        &self,
11565        command_id: CommandId,
11566        message: String,
11567        unsupported_transport: bool,
11568    ) {
11569        let now = Instant::now();
11570        let mut reasons = self.rejected_command_reasons.lock()
11571            .unwrap_or_else(|poisoned| poisoned.into_inner());
11572        reasons.retain(|_, reason| reason.expires_at > now);
11573        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11574            if let Some(&oldest) = reasons.keys().next() {
11575                reasons.remove(&oldest);
11576            }
11577        }
11578        reasons.insert(
11579            command_id,
11580            RejectedCommandReason {
11581                message,
11582                unsupported_transport,
11583                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11584            },
11585        );
11586    }
11587
11588    /// Removes and returns the kernel's reason for refusing `command_id`,
11589    /// if the drive loop has recorded one and it has not expired -- see
11590    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11591    /// wait loop calls this instead of blindly polling out its whole
11592    /// deadline when the command it is waiting on turns out to have been
11593    /// refused rather than merely slow.
11594    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11595        let now = Instant::now();
11596        let mut reasons = self.rejected_command_reasons.lock()
11597            .unwrap_or_else(|poisoned| poisoned.into_inner());
11598        match reasons.remove(&command_id) {
11599            Some(reason) if reason.expires_at > now => Some(reason),
11600            _ => None,
11601        }
11602    }
11603
11604    async fn dispatch_bounded(
11605        &self,
11606        command: ControlCommand,
11607        timeout_duration: Duration,
11608    ) -> Result<CommandId, NodeFailure> {
11609        let deadline = Instant::now() + timeout_duration;
11610        loop {
11611            match self.dispatch(command.clone()) {
11612                Ok(command_id) => return Ok(command_id),
11613                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11614                    sleep(Duration::from_millis(2)).await;
11615                }
11616                Err(error) => return Err(error),
11617            }
11618        }
11619    }
11620
11621    fn internal_session_snapshot(
11622        &self,
11623        address: &SessionAddress,
11624    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11625        self.validate_address(address)?;
11626        self.handle
11627            .snapshot()
11628            .sessions
11629            .clone()
11630            .into_iter()
11631            .find(|session| {
11632                session.instance_id == address.session.instance_id
11633                    && session.generation == address.session.generation
11634            })
11635            .ok_or_else(|| {
11636                failure(
11637                    NodeFailureCode::UnknownSession,
11638                    "session snapshot is unavailable",
11639                )
11640            })
11641    }
11642
11643    /// Confirms `address` names a live session spawned over the ACP
11644    /// transport and returns its current snapshot -- the gate every
11645    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11646    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11647    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11648    /// the session's own recorded `TransportKind` does, and that lives on
11649    /// the live snapshot, not the binding.
11650    fn require_acp_session(
11651        &self,
11652        address: &SessionAddress,
11653    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11654        let snapshot = self.internal_session_snapshot(address)?;
11655        if snapshot.transport != TransportKind::Acp {
11656            return Err(failure(
11657                NodeFailureCode::UnsupportedTransport,
11658                "this verb requires a session spawned over the ACP transport",
11659            ));
11660        }
11661        Ok(snapshot)
11662    }
11663
11664    async fn dispatch_history_bounded(
11665        &self,
11666        address: &SessionAddress,
11667        command: ControlCommand,
11668        operation: HistoryOperation,
11669    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11670        self.validate_address(address)?;
11671        let started_after = self.current_sequence();
11672        let command_id = self
11673            .dispatch_bounded(
11674                command,
11675                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11676            )
11677            .await?;
11678        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11679        let mut scan_after = started_after;
11680        let mut accepted_after = None;
11681        loop {
11682            let events = self
11683                .history
11684                .lock()
11685                .unwrap_or_else(|poisoned| poisoned.into_inner())
11686                .events
11687                .iter()
11688                .filter(|envelope| envelope.sequence > scan_after)
11689                .cloned()
11690                .collect::<Vec<_>>();
11691            for envelope in events {
11692                scan_after = scan_after.max(envelope.sequence);
11693                let NodeEvent::Control {
11694                    address: event_address,
11695                    event,
11696                } = envelope.event
11697                else {
11698                    continue;
11699                };
11700                if event_address != *address {
11701                    continue;
11702                }
11703                if event.command_id == Some(command_id) {
11704                    match &event.event {
11705                        ControlEventKind::CommandRejected { .. } => {
11706                            return Err(failure(
11707                                NodeFailureCode::InvalidRequest,
11708                                "history request was rejected",
11709                            ));
11710                        }
11711                        ControlEventKind::HistoryRequested {
11712                            operation: requested,
11713                            ..
11714                        } if requested == &operation => {
11715                            accepted_after = Some(envelope.sequence);
11716                            continue;
11717                        }
11718                        _ => {}
11719                    }
11720                }
11721                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11722                    match (&operation, &event.event) {
11723                        (
11724                            HistoryOperation::Discover { .. },
11725                            ControlEventKind::HistoryDiscovered { .. },
11726                        )
11727                        | (
11728                            HistoryOperation::Load { .. },
11729                            ControlEventKind::HistoryLoaded { .. },
11730                        ) => return self.internal_session_snapshot(address),
11731                        (_, ControlEventKind::HistoryFailed { .. }) => {
11732                            return Err(failure(
11733                                NodeFailureCode::BackendOperationFailed,
11734                                "history operation failed",
11735                            ));
11736                        }
11737                        _ => {}
11738                    }
11739                }
11740            }
11741            if Instant::now() >= deadline {
11742                return Err(failure(
11743                    NodeFailureCode::BackendBusy,
11744                    if accepted_after.is_some() {
11745                        "history operation did not settle before the bounded deadline"
11746                    } else {
11747                        "history operation was not accepted before the bounded deadline"
11748                    },
11749                ));
11750            }
11751            sleep(Duration::from_millis(2)).await;
11752        }
11753    }
11754
11755    async fn discover_history(
11756        &self,
11757        address: &SessionAddress,
11758        limit: u16,
11759    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11760        let query = HistoryQuery {
11761            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11762            limit,
11763        };
11764        query
11765            .validate()
11766            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11767        let snapshot = self
11768            .dispatch_history_bounded(
11769                address,
11770                ControlCommand::DiscoverHistory {
11771                    instance_id: address.session.instance_id,
11772                    query: query.clone(),
11773                },
11774                HistoryOperation::Discover { query },
11775            )
11776            .await?;
11777        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11778            return Err(failure(
11779                NodeFailureCode::BackendOperationFailed,
11780                "history discovery settled into an invalid state",
11781            ));
11782        }
11783        Ok(snapshot.history.candidates)
11784    }
11785
11786    async fn load_history(
11787        &self,
11788        address: &SessionAddress,
11789        candidate_id: String,
11790    ) -> Result<HistorySessionRecord, NodeFailure> {
11791        validate_candidate_id(&candidate_id)
11792            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11793        let snapshot = self
11794            .dispatch_history_bounded(
11795                address,
11796                ControlCommand::LoadHistory {
11797                    instance_id: address.session.instance_id,
11798                    candidate_id: candidate_id.clone(),
11799                },
11800                HistoryOperation::Load {
11801                    candidate_id: candidate_id.clone(),
11802                },
11803            )
11804            .await?;
11805        if snapshot.history.pending.is_some()
11806            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11807        {
11808            return Err(failure(
11809                NodeFailureCode::BackendOperationFailed,
11810                "history load settled into an invalid state",
11811            ));
11812        }
11813        snapshot.history.loaded.ok_or_else(|| {
11814            failure(
11815                NodeFailureCode::BackendOperationFailed,
11816                "history load produced no bounded session",
11817            )
11818        })
11819    }
11820
11821    /// `allow_clean_detachment`: `false` for the on-demand
11822    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11823    /// managed record must stay untouched, still live, for the whole call);
11824    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11825    /// reacts to the very clean-exit event that detaches the record out from
11826    /// under this same call — see `session_record_export_target_matches`.
11827    async fn export_context_pack_for_session_record(
11828        &self,
11829        record_id: &SessionRecordId,
11830        session: &SessionAddress,
11831        allow_clean_detachment: bool,
11832    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11833        // C2 replaces a relayed failure's prose with a canned per-code
11834        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11835        // distinct refusals are indistinguishable to anyone downstream --
11836        // and a refusal here expires a continuation, which keeps no field
11837        // for why. The prose stays on the node, where it was written.
11838        let outcome = self
11839            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11840            .await;
11841        if let Err(failure) = &outcome {
11842            tracing::warn!(
11843                record = %record_id,
11844                code = ?failure.code,
11845                reason = %failure.message,
11846                "session-record context-pack export refused",
11847            );
11848        }
11849        outcome
11850    }
11851
11852    async fn export_context_pack_for_session_record_inner(
11853        &self,
11854        record_id: &SessionRecordId,
11855        session: &SessionAddress,
11856        allow_clean_detachment: bool,
11857    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11858        // Several steps here refuse with the same BackendOperationFailed
11859        // code and no prose of their own, so which step failed is
11860        // otherwise unrecoverable -- and a refusal here expires a
11861        // continuation.
11862        let step = |name: &'static str| move |failure: NodeFailure| {
11863            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11864            failure
11865        };
11866        let record = self.record(record_id).map_err(step("record-lookup"))?;
11867        // `provider_session` is `Some` only for a provider whose resolved
11868        // runtime policy admits live identity verification (raw PTY plus a
11869        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11870        // The PTY-sidecar-observation shape (no fleet provider today, but
11871        // capability-keyed rather than vendor-locked) hardcodes
11872        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11873        // so its records go straight to `Live` with `provider_session: None`
11874        // and stay that way for the record's entire life -- that is this
11875        // shape's permanent resting state, not a race with observation, and
11876        // `discover_history`/`load_history` below do not need an identity to
11877        // do their job for it. `IdentityPending` is the actual race (an
11878        // identity-admitted provider whose observation has not landed yet):
11879        // it keeps refusing here rather than falling back, because that
11880        // provider's own history the fallback would otherwise trust is not
11881        // guaranteed to be exact without the identity it is still waiting on.
11882        let identity = match record.provider_session.clone() {
11883            Some(identity) => {
11884                if identity.key != ProviderSessionKey::SessionId {
11885                    return Err(step("provider-identity-key")(failure(
11886                        NodeFailureCode::BackendOperationFailed,
11887                        "managed session provider identity cannot bind history exactly",
11888                    )));
11889                }
11890                Some(identity)
11891            }
11892            None if record.state == ManagedSessionState::IdentityPending => {
11893                return Err(step("provider-session-identity")(failure(
11894                    NodeFailureCode::BackendOperationFailed,
11895                    "managed session has not yet observed its provider session identity",
11896                )));
11897            }
11898            None => None,
11899        };
11900        self.revalidate_session_record_context_export(
11901            &record,
11902            identity.as_ref(),
11903            session,
11904            allow_clean_detachment,
11905        ).map_err(step("revalidate-before-discovery"))?;
11906        let candidates = self
11907            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11908            .await
11909            .map_err(step("discover-history"))?;
11910        // With no verified identity to filter by, the discovered set must
11911        // still resolve to exactly one candidate: the same ambiguity refusal
11912        // an identity filter would otherwise produce is the only safety
11913        // property left available for this shape.
11914        let candidate_id = select_history_candidate(
11915            &candidates,
11916            identity.as_ref().map(|identity| identity.id.as_str()),
11917        ).map_err(step("unique-candidate"))?;
11918        let loaded = self.load_history(session, candidate_id.clone()).await
11919            .map_err(step("load-history"))?;
11920        if let Some(identity) = identity.as_ref() {
11921            if loaded.session_id != identity.id {
11922                return Err(failure(
11923                    NodeFailureCode::SessionRecordConflict,
11924                    "loaded history identity does not match the managed session record",
11925                ));
11926            }
11927        }
11928        self.revalidate_session_record_context_export(
11929            &record,
11930            identity.as_ref(),
11931            session,
11932            allow_clean_detachment,
11933        )?;
11934        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11935            .map_err(step("revalidate-loaded-history"))?;
11936        let pack = self.materialize_context_pack(session).await
11937            .map_err(step("materialize-context-pack"))?;
11938        self.commit_context_pack_for_session_record(
11939            &record,
11940            identity.as_ref(),
11941            session,
11942            &candidate_id,
11943            &loaded,
11944            pack,
11945            allow_clean_detachment,
11946        )
11947    }
11948
11949    async fn export_context_pack(
11950        &self,
11951        address: &SessionAddress,
11952    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11953        let pack = self.materialize_context_pack(address).await?;
11954        self.commit_context_pack(pack)
11955    }
11956
11957    async fn materialize_context_pack(
11958        &self,
11959        address: &SessionAddress,
11960    ) -> Result<NodeContextPack, NodeFailure> {
11961        let snapshot = self.internal_session_snapshot(address)?;
11962        if !context_pack_source_status_is_usable(&snapshot.status) {
11963            return Err(failure(
11964                NodeFailureCode::InvalidRequest,
11965                "context export requires a running or successfully exited source session",
11966            ));
11967        }
11968        if snapshot.history.pending.is_some()
11969            || snapshot.history.loaded_candidate_id.is_none()
11970        {
11971            return Err(failure(
11972                NodeFailureCode::InvalidRequest,
11973                "context export requires settled loaded history",
11974            ));
11975        }
11976        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11977            failure(
11978                NodeFailureCode::InvalidRequest,
11979                "context export requires settled loaded history",
11980            )
11981        })?;
11982        let source_provider = snapshot.agent_id.clone();
11983        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11984        let repository = self
11985            .context_pack_repository(&address.workspace_id)
11986            .await?;
11987        let current = self.internal_session_snapshot(address)?;
11988        if !context_pack_source_status_is_usable(&current.status)
11989            || current.agent_id != source_provider
11990            || current.history.pending.is_some()
11991            || current.history.loaded_candidate_id != loaded_candidate_id
11992            || current.history.loaded.as_ref() != Some(&loaded)
11993        {
11994            return Err(failure(
11995                NodeFailureCode::ContextPackMaterializationFailed,
11996                "context source changed during bounded repository capture",
11997            ));
11998        }
11999        NodeContextPack::export_with_repository(
12000            ContextPackLineageReceipt {
12001                source_node_id: self.node_id.clone(),
12002                source_session: address.clone(),
12003                source_provider,
12004            },
12005            &loaded,
12006            Some(repository),
12007        )
12008        .map_err(|_| {
12009            failure(
12010                NodeFailureCode::ContextPackMaterializationFailed,
12011                "bounded context pack export failed",
12012            )
12013        })
12014    }
12015
12016    /// Write-through to the durable [`ContextPackStore`], if one is
12017    /// configured (`state_path` is `Some`). A node with no durable state
12018    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12019    /// open at all and this is a no-op, matching `persist_state`'s own
12020    /// "durability optional, degrade gracefully when unconfigured" idiom.
12021    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12022        let mut store = self
12023            .context_pack_store
12024            .lock()
12025            .unwrap_or_else(|poisoned| poisoned.into_inner());
12026        let Some(store) = store.as_mut() else {
12027            return Ok(());
12028        };
12029        store.commit(pack).map_err(|_| {
12030            failure(
12031                NodeFailureCode::ContextPackMaterializationFailed,
12032                "durable context pack commit failed",
12033            )
12034        })
12035    }
12036
12037    fn commit_context_pack(
12038        &self,
12039        pack: NodeContextPack,
12040    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12041        self.commit_context_pack_durably(&pack)?;
12042        self.context_catalog
12043            .write()
12044            .unwrap_or_else(|poisoned| poisoned.into_inner())
12045            .insert(pack)
12046            .map_err(|_| {
12047                failure(
12048                    NodeFailureCode::ContextPackMaterializationFailed,
12049                    "context pack catalog is full",
12050                )
12051            })
12052    }
12053
12054    fn commit_context_pack_for_session_record(
12055        &self,
12056        expected: &ManagedSessionRecord,
12057        identity: Option<&ProviderSessionIdentity>,
12058        session: &SessionAddress,
12059        candidate_id: &str,
12060        loaded: &HistorySessionRecord,
12061        pack: NodeContextPack,
12062        allow_clean_detachment: bool,
12063    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12064        let current_root = self.workspace_root(&expected.workspace_id)?;
12065        let _transaction = self
12066            .state_transaction
12067            .lock()
12068            .unwrap_or_else(|poisoned| poisoned.into_inner());
12069        let records = self
12070            .session_records
12071            .lock()
12072            .unwrap_or_else(|poisoned| poisoned.into_inner());
12073        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12074            failure(
12075                NodeFailureCode::SessionRecordConflict,
12076                "managed session record disappeared during context export",
12077            )
12078        })?;
12079        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12080            return Err(failure(
12081                NodeFailureCode::SessionRecordConflict,
12082                "managed session record changed during context export",
12083            ));
12084        }
12085        let bindings = self
12086            .session_bindings
12087            .lock()
12088            .unwrap_or_else(|poisoned| poisoned.into_inner());
12089        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12090            failure(
12091                NodeFailureCode::SessionRecordConflict,
12092                "managed session runtime binding disappeared during context export",
12093            )
12094        })?;
12095        let runtime = self
12096            .handle
12097            .snapshot()
12098            .sessions
12099            .iter()
12100            .find(|snapshot| {
12101                snapshot.instance_id == session.session.instance_id
12102                    && snapshot.generation == session.session.generation
12103            })
12104            .cloned()
12105            .ok_or_else(|| {
12106                failure(
12107                    NodeFailureCode::SessionRecordConflict,
12108                    "managed session runtime disappeared during context export",
12109                )
12110            })?;
12111        let receipt = pack.receipt();
12112        if !session_record_context_export_binding_is_exact(
12113            expected,
12114            current,
12115            identity,
12116            session,
12117            binding,
12118            &runtime.agent_id,
12119            &current_root,
12120            allow_clean_detachment,
12121        ) || !session_record_context_export_source_is_usable(
12122            current,
12123            &runtime.status,
12124            allow_clean_detachment,
12125        ) || runtime.history.pending.is_some()
12126            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12127            || runtime.history.loaded.as_ref() != Some(loaded)
12128            || receipt.lineage.source_node_id != self.node_id
12129            || receipt.lineage.source_session != *session
12130            || receipt.lineage.source_provider != current.provider
12131            || receipt.source_message_count != loaded.message_count
12132        {
12133            return Err(failure(
12134                NodeFailureCode::SessionRecordConflict,
12135                "managed session source changed before context catalog commit",
12136            ));
12137        }
12138        self.commit_context_pack_durably(&pack)?;
12139        self.context_catalog
12140            .write()
12141            .unwrap_or_else(|poisoned| poisoned.into_inner())
12142            .insert(pack)
12143            .map_err(|_| {
12144                failure(
12145                    NodeFailureCode::ContextPackMaterializationFailed,
12146                    "context pack catalog is full",
12147                )
12148            })
12149    }
12150
12151    async fn context_pack_repository(
12152        &self,
12153        workspace_id: &WorkspaceId,
12154    ) -> Result<ContextPackRepository, NodeFailure> {
12155        let first = self
12156            .context_pack_repository_observation(workspace_id)
12157            .await?;
12158        let second = self
12159            .context_pack_repository_observation(workspace_id)
12160            .await?;
12161        stable_context_pack_repository(first, second)
12162    }
12163
12164    async fn context_pack_repository_observation(
12165        &self,
12166        workspace_id: &WorkspaceId,
12167    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12168        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12169        let head = if git.is_repository {
12170            let root = self.workspace_root(workspace_id)?;
12171            match resolve_base_commit_with_timeout(
12172                &root,
12173                "HEAD",
12174                GIT_COMMAND_TIMEOUT_MS,
12175            )
12176            .await
12177            {
12178                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12179                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12180                    ContextPackRepositoryHead::Unborn
12181                }
12182                Err(_) => {
12183                    return Err(failure(
12184                        NodeFailureCode::ContextPackMaterializationFailed,
12185                        "context pack HEAD observation failed",
12186                    ));
12187                }
12188            }
12189        } else {
12190            ContextPackRepositoryHead::NotRepository
12191        };
12192        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12193        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12194            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12195                failure(
12196                    NodeFailureCode::InvalidRepositoryPath,
12197                    "context pack selected file path is invalid",
12198                )
12199            })?;
12200            match self.read_workspace_file(workspace_id.clone(), path).await {
12201                Ok(WorkspaceFileRead {
12202                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12203                    ..
12204                }) => files.push(ContextPackRepositoryFileSource::utf8(
12205                    selected_path,
12206                    text,
12207                    byte_len,
12208                )),
12209                Ok(WorkspaceFileRead {
12210                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12211                    ..
12212                }) => files.push(ContextPackRepositoryFileSource::skipped(
12213                    selected_path,
12214                    ContextPackSelectedFileSkipReason::NonUtf8,
12215                    Some(byte_len),
12216                )),
12217                Ok(WorkspaceFileRead {
12218                    content: WorkspaceFileContent::TooLarge { .. },
12219                    ..
12220                }) => files.push(ContextPackRepositoryFileSource::skipped(
12221                    selected_path,
12222                    ContextPackSelectedFileSkipReason::TooLarge,
12223                    None,
12224                )),
12225                Err(error) => {
12226                    let reason = if matches!(
12227                        error.code,
12228                        NodeFailureCode::InvalidRepositoryPath
12229                            | NodeFailureCode::RepositoryFileNotRegular
12230                            | NodeFailureCode::RepositoryPathUnsafe
12231                    ) {
12232                        ContextPackSelectedFileSkipReason::Unsafe
12233                    } else {
12234                        ContextPackSelectedFileSkipReason::Unavailable
12235                    };
12236                    files.push(ContextPackRepositoryFileSource::skipped(
12237                        selected_path,
12238                        reason,
12239                        None,
12240                    ));
12241                }
12242            }
12243        }
12244        Ok(ContextPackRepositoryObservation { head, git, files })
12245    }
12246
12247    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12248        let referenced_by_binding = self
12249            .session_bindings
12250            .lock()
12251            .unwrap_or_else(|poisoned| poisoned.into_inner())
12252            .values()
12253            .any(|binding| {
12254                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12255            });
12256        let referenced_by_record = self
12257            .session_records
12258            .lock()
12259            .unwrap_or_else(|poisoned| poisoned.into_inner())
12260            .records
12261            .values()
12262            .any(|record| record.context_id.as_ref() == Some(context_id));
12263        let referenced_by_materialization = self
12264            .materializations
12265            .lock()
12266            .unwrap_or_else(|poisoned| poisoned.into_inner())
12267            .values()
12268            .any(|ownership| {
12269                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12270            });
12271        if referenced_by_binding
12272            || referenced_by_record
12273            || referenced_by_materialization
12274        {
12275            return Err(failure(
12276                NodeFailureCode::ContextPackBusy,
12277                "context pack is still referenced",
12278            ));
12279        }
12280        let mut catalog = self.context_catalog
12281            .write()
12282            .unwrap_or_else(|poisoned| poisoned.into_inner());
12283        if catalog.get(context_id).is_none() {
12284            return Err(failure(
12285                NodeFailureCode::UnknownContextPack,
12286                "context pack is unavailable",
12287            ));
12288        }
12289        self.spawn_idempotency
12290            .lock()
12291            .unwrap_or_else(|poisoned| poisoned.into_inner())
12292            .entries
12293            .retain(|_, entry| !entry.value.references_context(context_id));
12294        let removed = catalog.remove(context_id);
12295        debug_assert!(removed.is_some(), "validated context pack remains present");
12296        Ok(())
12297    }
12298
12299    async fn dispatch_input_bounded(
12300        &self,
12301        address: &SessionAddress,
12302        action: InputAction,
12303    ) -> Result<(), NodeFailure> {
12304        let started_after = self.current_sequence();
12305        self.pty_input_marks
12306            .lock()
12307            .unwrap_or_else(|poisoned| poisoned.into_inner())
12308            .insert(address.session.instance_id, (Instant::now(), false));
12309        self.pty_input_wake.notify_one();
12310        let command_id = self
12311            .dispatch_bounded(
12312                ControlCommand::SendInput {
12313                    instance_id: address.session.instance_id,
12314                    action,
12315                },
12316                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12317            )
12318            .await?;
12319        // A semantic prompt may spend the provider's full readiness budget before
12320        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12321        // but strictly beyond every readiness policy in this node's catalog.
12322        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12323        let mut scan_after = started_after;
12324        let mut accepted_after = None;
12325        loop {
12326            let events = self
12327                .history
12328                .lock()
12329                .unwrap_or_else(|poisoned| poisoned.into_inner())
12330                .events
12331                .iter()
12332                .filter(|envelope| envelope.sequence > scan_after)
12333                .cloned()
12334                .collect::<Vec<_>>();
12335            for envelope in events {
12336                scan_after = scan_after.max(envelope.sequence);
12337                let NodeEvent::Control {
12338                    address: event_address,
12339                    event,
12340                } = envelope.event
12341                else {
12342                    continue;
12343                };
12344                if event_address != *address {
12345                    continue;
12346                }
12347                if event.command_id == Some(command_id) {
12348                    match &event.event {
12349                        ControlEventKind::CommandRejected { message } => {
12350                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12351                        }
12352                        ControlEventKind::InputRequested { .. } => {
12353                            accepted_after = Some(envelope.sequence);
12354                            continue;
12355                        }
12356                        _ => {}
12357                    }
12358                }
12359                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12360                    match &event.event {
12361                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12362                        ControlEventKind::InputFailed { message, .. } => {
12363                            return Err(failure(
12364                                NodeFailureCode::BackendOperationFailed,
12365                                message,
12366                            ));
12367                        }
12368                        _ => {}
12369                    }
12370                }
12371            }
12372            if Instant::now() >= deadline {
12373                return Err(failure(
12374                    NodeFailureCode::BackendBusy,
12375                    if accepted_after.is_some() {
12376                        "input was accepted but did not settle before the bounded deadline"
12377                    } else {
12378                        "input was not accepted before the bounded deadline"
12379                    },
12380                ));
12381            }
12382            sleep(Duration::from_millis(2)).await;
12383        }
12384    }
12385
12386    async fn dispatch_resize_bounded(
12387        &self,
12388        address: &SessionAddress,
12389        size: gate4agent_types::TerminalSize,
12390    ) -> Result<(), NodeFailure> {
12391        let started_after = self.current_sequence();
12392        let command_id = self
12393            .dispatch_bounded(
12394                ControlCommand::Resize {
12395                    instance_id: address.session.instance_id,
12396                    size,
12397                },
12398                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12399            )
12400            .await?;
12401        let deadline = Instant::now() + Duration::from_millis(MUTATION_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::ResizeRequested {
12432                            size: requested_size,
12433                            ..
12434                        } if *requested_size == size => {
12435                            accepted_after = Some(envelope.sequence);
12436                            continue;
12437                        }
12438                        _ => {}
12439                    }
12440                }
12441                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12442                    match &event.event {
12443                        ControlEventKind::Resized { size: settled_size }
12444                            if *settled_size == size =>
12445                        {
12446                            return Ok(());
12447                        }
12448                        ControlEventKind::ResizeFailed { message } => {
12449                            return Err(failure(
12450                                NodeFailureCode::BackendOperationFailed,
12451                                message,
12452                            ));
12453                        }
12454                        _ => {}
12455                    }
12456                }
12457            }
12458            if Instant::now() >= deadline {
12459                return Err(failure(
12460                    NodeFailureCode::BackendBusy,
12461                    if accepted_after.is_some() {
12462                        "resize was accepted but did not settle before the bounded deadline"
12463                    } else {
12464                        "resize was not accepted before the bounded deadline"
12465                    },
12466                ));
12467            }
12468            sleep(Duration::from_millis(2)).await;
12469        }
12470    }
12471
12472    async fn spawn_session(
12473        &self,
12474        workspace_id: WorkspaceId,
12475        provider: AgentId,
12476        mode: SessionMode,
12477        terminal_size: gate4agent_types::TerminalSize,
12478        initial_prompt: Option<String>,
12479    ) -> Result<SessionAddress, NodeFailure> {
12480        if self.managed_worktrees.lock()
12481            .unwrap_or_else(|poisoned| poisoned.into_inner())
12482            .lease_for_workspace(&workspace_id)
12483            .is_some()
12484        {
12485            return Err(failure(
12486                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12487                "managed worktree targets may only be spawned through their lease request",
12488            ));
12489        }
12490        self
12491            .spawn_session_with_deadline(
12492                workspace_id,
12493                provider,
12494                mode,
12495                terminal_size,
12496                initial_prompt,
12497                None,
12498                None,
12499                None,
12500                None,
12501                None,
12502                SpawnRecordPolicy::ProviderIdentityOnly,
12503                None,
12504                &[],
12505                None,
12506                // `NodeRequest::Spawn` (this method's only caller) carries no
12507                // approval-level selection of its own -- see
12508                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12509                // the one operator-wire path that can choose something other
12510                // than the axis default.
12511                ApprovalLevel::default(),
12512                None,
12513                None,
12514            )
12515            .await
12516            .map(|(session, _runtime_policy)| session)
12517    }
12518
12519    async fn spawn_session_with_deadline(
12520        &self,
12521        workspace_id: WorkspaceId,
12522        provider: AgentId,
12523        mode: SessionMode,
12524        terminal_size: gate4agent_types::TerminalSize,
12525        initial_prompt: Option<String>,
12526        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12527        bundle: Option<ResolvedBundleReceipt>,
12528        context: Option<ResolvedContextPackReceipt>,
12529        managed_authority: Option<ManagedWorktreeLeaseId>,
12530        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12531        record_policy: SpawnRecordPolicy,
12532        deadline: Option<Instant>,
12533        required_capabilities: &[ProviderRuntimeCapability],
12534        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12535        approval_level: ApprovalLevel,
12536        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12537        // Opaque dig2browser station profile id (ids only). Feature
12538        // `dig2-station-probe`: after probe at resolve, spawn takes an
12539        // exclusive node-local lease; cleared on session end. Never
12540        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12541        // already happened on the receipt path).
12542        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12543    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12544        let reserved_lease = self.managed_worktrees.lock()
12545            .unwrap_or_else(|poisoned| poisoned.into_inner())
12546            .lease_for_workspace(&workspace_id);
12547        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12548            (Some(expected), Some(provided)) if expected == provided => {}
12549            (None, None) => {}
12550            _ => {
12551                return Err(failure(
12552                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12553                    "spawn lacks exact managed worktree lease authority",
12554                ));
12555            }
12556        }
12557        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12558        // bindings) on the same workspace cwd. Parallel path: managed worktree
12559        // spawn so each profile gets its own workspace_id.
12560        self.ensure_workspace_environment_profile_compatible(
12561            &workspace_id,
12562            environment_profile.as_ref(),
12563        )?;
12564        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12565            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12566            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12567            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12568            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12569        };
12570        // This session's own transport -- computed once, here, from `mode`,
12571        // and reused both to admit spawn (below) and, further down, to
12572        // compute the final `runtime_policy` this session actually operates
12573        // under (`provider_runtime::policy_for_transport`). Registration
12574        // reuses this exact value rather than recomputing it a second time.
12575        let transport = match mode {
12576            SessionMode::Pty => TransportKind::Pty,
12577            SessionMode::Inline => TransportKind::Pipe,
12578            SessionMode::Acp => TransportKind::Acp,
12579        };
12580        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12581        let acp_transport = self.provider_supports_acp_transport(&provider);
12582        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12583            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12584                NodeFailureCode::UnsupportedSpawnCapability,
12585                "pre-admitted provider runtime no longer satisfies spawn requirements",
12586            ))?;
12587            runtime_policy
12588        } else if let Some(deadline) = deadline {
12589            let remaining = spawn_deadline_remaining(deadline)?;
12590            timeout(
12591                remaining,
12592                self.admit_provider_runtime(&provider, runtime_requirement),
12593            )
12594            .await
12595            .map_err(|_| {
12596                failure(
12597                    NodeFailureCode::SpawnDeadlineExceeded,
12598                    "provider admission exceeded the spawn deadline",
12599                )
12600            })??
12601        } else {
12602            self.admit_provider_runtime(&provider, runtime_requirement).await?
12603        };
12604        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12605        // The single point where this session's `runtime_policy` becomes
12606        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12607        // branch above) only ever compute a PTY-terminal-verification-shaped
12608        // value, sound outright only for `TransportKind::Pty` (Pipe still
12609        // relies on that same value downstream -- see `policy_for_transport`'s
12610        // own doc for why). `policy_for_transport` is the explicit,
12611        // exhaustive match Item 1 asks for -- this is the ONE place its
12612        // output becomes the session's `runtime_policy`, so every later
12613        // reader (`ControlCommand::Start`, `session_bindings`, and every
12614        // engine gate that reads `state.runtime_policy`) sees the same,
12615        // correctly-computed value.
12616        let runtime_policy =
12617            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12618        if required_capabilities
12619            .iter()
12620            .any(|capability| !runtime_policy.admits(*capability))
12621        {
12622            return Err(failure(
12623                NodeFailureCode::UnsupportedSpawnCapability,
12624                "provider runtime does not admit a required spawn capability",
12625            ));
12626        }
12627        if let Some(deadline) = deadline {
12628            spawn_deadline_remaining(deadline)?;
12629        }
12630        self.ensure_binding_capacity()?;
12631        let working_directory = self.workspace_root(&workspace_id)?;
12632        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12633        let session = SessionKey {
12634            instance_id,
12635            generation: SessionGeneration(1),
12636        };
12637        let address = SessionAddress {
12638            workspace_id: workspace_id.clone(),
12639            session,
12640        };
12641        // This is the request-accepted checkpoint: admission (managed
12642        // worktree authority, runtime capability, dispatch deadline) already
12643        // passed and a concrete session identity is minted. It is a distinct
12644        // state from the process actually running (`ControlEventKind::Running`,
12645        // logged in `publish_control`) or the terminal outcome (`Exited`/
12646        // `Failed`, logged there too) -- this line exists so that gap is no
12647        // longer silent.
12648        tracing::info!(
12649            node_id = %self.node_id,
12650            workspace_id = %workspace_id,
12651            provider = %provider,
12652            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12653            session = ?address.session,
12654            "session spawn request accepted",
12655        );
12656        let mut prepared_spawn = SpawnPreparer::prepare(
12657            self,
12658            SpawnPrepareRequest {
12659                instance_id,
12660                address: &address,
12661                provider: &provider,
12662                mode,
12663                environment_profile: environment_profile.as_ref(),
12664                bundle: bundle.as_ref(),
12665                context: context.as_ref(),
12666                managed_authority: managed_authority.as_ref(),
12667                harness_mcp,
12668                approval_level,
12669                network_allowlist: network_allowlist.as_ref(),
12670                browser_profile_id,
12671            },
12672        )?;
12673        let materialization_id = prepared_spawn.materialization_id.take();
12674        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12675        let record_id = self.bind_spawn_session_with_materialization(
12676            &address,
12677            provider.clone(),
12678            mode,
12679            runtime_policy,
12680            record_policy,
12681            environment_profile,
12682            bundle,
12683            context,
12684            materialization_id,
12685        )?;
12686        prepared_spawn.retain_session_guards();
12687        // `transport` was already computed above, alongside this session's
12688        // `runtime_policy`; reused here for `Register` rather than derived a
12689        // second time.
12690        let agent_id = provider;
12691        let register_command_id = match self.dispatch_bounded(
12692            ControlCommand::Register {
12693                instance_id,
12694                agent_id,
12695                transport,
12696            },
12697            dispatch_timeout,
12698        )
12699        .await {
12700            Ok(command_id) => command_id,
12701            Err(error) => {
12702                if let Some(binding) = self.remove_binding(&address) {
12703                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12704                }
12705                if let Some(record_id) = record_id.as_ref() {
12706                    self.discard_record(record_id)?;
12707                }
12708                return Err(spawn_dispatch_error(error, deadline));
12709            }
12710        };
12711        let start_timeout = match spawn_dispatch_timeout(deadline) {
12712            Ok(timeout) => timeout,
12713            Err(error) => {
12714                let recovery = self
12715                    .rollback_spawn(
12716                        &address,
12717                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12718                    )
12719                    .await;
12720                return match recovery {
12721                    Ok(()) => {
12722                        if let Some(record_id) = record_id.as_ref() {
12723                            self.discard_record(record_id)?;
12724                        }
12725                        Err(error)
12726                    }
12727                    Err(recovery_error) => {
12728                        if let Some(record_id) = record_id.as_ref() {
12729                            self.mark_record_error(
12730                                record_id,
12731                                "spawn deadline elapsed and registration recovery failed",
12732                            )?;
12733                        }
12734                        Err(recovery_error)
12735                    }
12736                };
12737            }
12738        };
12739        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12740            if let Err(recovery_error) = self
12741                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12742                .await
12743            {
12744                return Err(recovery_error);
12745            }
12746            if let Some(record_id) = record_id.as_ref() {
12747                self.discard_record(record_id)?;
12748            }
12749            return Err(start_error);
12750        }
12751        let start_result = self
12752            .dispatch_bounded(
12753                ControlCommand::Start {
12754                    instance_id,
12755                    runtime_policy,
12756                    request: StartRequest {
12757                        working_directory,
12758                        terminal_size,
12759                        initial_prompt,
12760                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12761                        approval_level,
12762                    },
12763                },
12764                start_timeout,
12765            )
12766            .await;
12767        let start_command_id = match start_result {
12768            Ok(command_id) => command_id,
12769            Err(start_error) => {
12770                let start_error = spawn_dispatch_error(start_error, deadline);
12771                let recovery = self
12772                    .rollback_spawn(
12773                        &address,
12774                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12775                    )
12776                    .await;
12777                return match recovery {
12778                    Ok(()) => {
12779                        if let Some(record_id) = record_id.as_ref() {
12780                            self.discard_record(record_id)?;
12781                        }
12782                        Err(start_error)
12783                    }
12784                    Err(recovery_error) => {
12785                        let diagnostic = format!(
12786                            "start failed and registration recovery failed: {}",
12787                            recovery_error.message,
12788                        );
12789                        if let Some(record_id) = record_id.as_ref() {
12790                            self.mark_record_error(record_id, &diagnostic)?;
12791                        }
12792                        Err(failure(recovery_error.code, &diagnostic))
12793                    }
12794                };
12795            }
12796        };
12797
12798        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12799        loop {
12800            // `Register`/`Start` only fail synchronously for a port-level
12801            // ingress problem (see `dispatch_bounded`); the kernel refusing
12802            // either command's content -- an unsupported transport, an
12803            // unknown agent -- lands one drive-loop tick later, and no
12804            // session ever appears in `self.handle.snapshot()` for it to
12805            // notice below. Check for that named reason before falling
12806            // through to the blind presence poll, so a rejected spawn
12807            // reports why instead of just timing out.
12808            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12809                .or_else(|| self.take_rejected_command_reason(start_command_id))
12810            {
12811                self.rollback_spawn(
12812                    &address,
12813                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12814                ).await.map_err(|error| {
12815                        failure(
12816                            error.code,
12817                            &format!(
12818                                "spawn was rejected ({}) and registration recovery failed: {}",
12819                                reason.message, error.message
12820                            ),
12821                        )
12822                    })?;
12823                if let Some(record_id) = record_id.as_ref() {
12824                    self.discard_record(record_id)?;
12825                }
12826                let code = if reason.unsupported_transport {
12827                    NodeFailureCode::UnsupportedTransport
12828                } else {
12829                    NodeFailureCode::BackendOperationFailed
12830                };
12831                return Err(failure(code, &reason.message));
12832            }
12833            if let Some(current) = self
12834                .handle
12835                .snapshot()
12836                .sessions
12837                .iter()
12838                .find(|current| current.instance_id == instance_id)
12839            {
12840                if current.generation != session.generation {
12841                    let divergence = failure(
12842                        NodeFailureCode::StaleGeneration,
12843                        "spawn generation diverged",
12844                    );
12845                    return match self
12846                        .rollback_spawn(
12847                            &address,
12848                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12849                        )
12850                        .await
12851                    {
12852                        Ok(()) => {
12853                            if let Some(record_id) = record_id.as_ref() {
12854                                self.discard_record(record_id)?;
12855                            }
12856                            Err(divergence)
12857                        }
12858                        Err(recovery_error) => {
12859                            if let Some(record_id) = record_id.as_ref() {
12860                                self.mark_record_error(
12861                                    record_id,
12862                                    "spawn generation diverged and recovery failed",
12863                                )?;
12864                            }
12865                            Err(recovery_error)
12866                        }
12867                    };
12868                }
12869                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12870                    prepared_spawn.retain_launch_guards();
12871                    return Ok((address, runtime_policy));
12872                }
12873            }
12874            if Instant::now() >= commit_deadline {
12875                self.rollback_spawn(
12876                    &address,
12877                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12878                ).await.map_err(|error| {
12879                        failure(
12880                            error.code,
12881                            &format!(
12882                                "start did not commit and registration recovery failed: {}",
12883                                error.message
12884                            ),
12885                        )
12886                    })?;
12887                if let Some(record_id) = record_id.as_ref() {
12888                    self.discard_record(record_id)?;
12889                }
12890                return Err(failure(
12891                    if deadline.is_some() {
12892                        NodeFailureCode::SpawnDeadlineExceeded
12893                    } else {
12894                        NodeFailureCode::BackendBusy
12895                    },
12896                    "start did not commit before the bounded deadline; registration was removed",
12897                ));
12898            }
12899            sleep(Duration::from_millis(2)).await;
12900        }
12901    }
12902
12903    fn effective_spawn_runtime_policy(
12904        &self,
12905        runtime_policy: ProviderRuntimePolicy,
12906    ) -> ProviderRuntimePolicy {
12907        #[cfg(feature = "fixture")]
12908        let runtime_policy = if self.fixture_semantic_hook_policy {
12909            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12910                .expect("monitoring Hook fixture policy is internally valid")
12911        } else {
12912            runtime_policy
12913        };
12914        runtime_policy
12915    }
12916
12917    fn verify_harness_mcp_before_start(
12918        &self,
12919        prepared: Option<&PreparedHarnessMcpSpawn>,
12920    ) -> Result<(), NodeFailure> {
12921        prepared
12922            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12923            .transpose()
12924            .map(|_| ())
12925            .map_err(harness_mcp_failure)
12926    }
12927
12928    async fn rollback_spawn(
12929        &self,
12930        address: &SessionAddress,
12931        commit_timeout: Duration,
12932    ) -> Result<(), NodeFailure> {
12933        self.dispatch_bounded(
12934            ControlCommand::Remove {
12935                instance_id: address.session.instance_id,
12936            },
12937            commit_timeout,
12938        )
12939        .await?;
12940        self.wait_until_removed(address, commit_timeout).await?;
12941        if let Some(binding) = self.remove_binding(address) {
12942            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12943        }
12944        Ok(())
12945    }
12946
12947    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12948        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12949        self.dispatch_bounded(
12950            ControlCommand::Remove {
12951                instance_id: address.session.instance_id,
12952            },
12953            commit_timeout,
12954        )
12955            .await?;
12956        self.wait_until_removed(address, commit_timeout).await?;
12957        let removed = self.remove_binding(address);
12958        if let Some(binding) = removed.as_ref() {
12959            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12960        }
12961        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12962            let snapshot = self.managed_worktrees.lock()
12963                .unwrap_or_else(|poisoned| poisoned.into_inner())
12964                .release_session(
12965                    &lease_id,
12966                    self.incarnation_id,
12967                    address.session.instance_id,
12968                    address.session.generation,
12969                    unix_time_ms(),
12970                );
12971            if self.persist_state().is_err() {
12972                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12973            }
12974            if let Some(snapshot) = snapshot {
12975                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12976            }
12977            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12978        }
12979        Ok(())
12980    }
12981
12982    /// Polls until `address`'s session leaves the engine's own session map,
12983    /// or the bounded deadline expires.
12984    ///
12985    /// A `Remove` sent while the session is not yet in a terminal status
12986    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12987    /// once by the engine (`SessionStatus::allows_remove`) and
12988    /// then discarded -- nothing re-issues it on its own, so the removal
12989    /// that would satisfy this wait never arrives and the caller was
12990    /// guaranteed to time out even though the session settles microseconds
12991    /// later. So this re-dispatches on every poll tick: a session that
12992    /// settles mid-wait -- the common case right after a `Stop` -- still
12993    /// gets removed inside the same bounded deadline.
12994    ///
12995    /// It re-dispatches only once the status will ACCEPT the command. A
12996    /// rejected re-dispatch is not the harmless no-op this loop first
12997    /// assumed: every rejection publishes a `ControlEventKind::Command
12998    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12999    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13000    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13001    /// out to the harness and every attached client. Gated, the same
13002    /// teardown sends exactly one `Remove`, at the first tick the engine
13003    /// can act on it.
13004    ///
13005    /// The status is read from the snapshot this loop already takes, so
13006    /// the gate costs nothing extra. It races -- the status can move
13007    /// between the read and the dispatch -- and that is fine: a rare lost
13008    /// race is one rejection, and the next tick retries.
13009    ///
13010    /// Re-dispatch is separately rate-limited to
13011    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13012    /// leaves a second burst behind it. Once the session settles, every
13013    /// 2ms tick sent another `Remove` while the accepted one was still in
13014    /// flight, and each of those landed on an instance the engine had
13015    /// already dropped -- `agent instance … is not registered`, observed
13016    /// live as a tail of rejections immediately after the `session
13017    /// removed` line. The presence poll stays at 2ms so the wait still
13018    /// exits promptly; only the sending slows down.
13019    ///
13020    /// Rate-limited rather than once-only on purpose: the status gate
13021    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13022    /// others (a pending operation, an in-flight capability probe or
13023    /// history request) that clear on their own. A single shot would turn
13024    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13025    /// get their retries, just twenty of them across the deadline instead
13026    /// of a thousand.
13027    async fn wait_until_removed(
13028        &self,
13029        address: &SessionAddress,
13030        commit_timeout: Duration,
13031    ) -> Result<(), NodeFailure> {
13032        let deadline = Instant::now() + commit_timeout;
13033        let mut next_dispatch = Instant::now();
13034        loop {
13035            let snapshot = self.handle.snapshot();
13036            let status = snapshot
13037                .sessions
13038                .iter()
13039                .find(|session| session.instance_id == address.session.instance_id)
13040                .map(|session| session.status.clone());
13041            let Some(status) = status else {
13042                return Ok(());
13043            };
13044            let now = Instant::now();
13045            if now >= deadline {
13046                return Err(failure(
13047                    NodeFailureCode::BackendBusy,
13048                    "remove did not commit before the bounded deadline; session binding was retained",
13049                ));
13050            }
13051            if status.allows_remove() && now >= next_dispatch {
13052                let _ = self.dispatch(ControlCommand::Remove {
13053                    instance_id: address.session.instance_id,
13054                });
13055                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13056            }
13057            sleep(Duration::from_millis(2)).await;
13058        }
13059    }
13060
13061    fn resync(&self, after_sequence: u64) -> NodeResponse {
13062        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13063        let event_sequence = history.last_sequence;
13064        let oldest_available_sequence = history.replay_floor_sequence;
13065        let events = history
13066            .events
13067            .iter()
13068            .filter(|event| event.sequence > after_sequence)
13069            .cloned()
13070            .collect();
13071        drop(history);
13072        NodeResponse::Resync {
13073            event_sequence,
13074            oldest_available_sequence,
13075            snapshot: self.snapshot(),
13076            events,
13077        }
13078    }
13079}
13080
13081/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13082/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13083/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13084/// "name what crossed this boundary" reasoning, one layer upstream of it).
13085fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13086    match kind {
13087        AgentStreamChunkKindV1::Text { .. } => "text",
13088        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13089        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13090        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13091        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13092        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13093        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13094    }
13095}
13096
13097/// Serves this wire on the local endpoint, and -- when `call_home` names a
13098/// relay -- over an outbound connection to it as well.
13099///
13100/// The two live in one future rather than a fourth arm of `run`'s own
13101/// `select!` because they are one concern: this is where the node's wire
13102/// is served, and the direction a given connection was opened in does not
13103/// change what happens on it. It also means the existing graceful drain
13104/// below covers both without being duplicated.
13105///
13106/// Either side finishing ends the pair, which is correct: the listener
13107/// only returns on shutdown or a bind failure, and `call_home_loop` only
13108/// returns on shutdown -- a failed relay connection is retried inside it,
13109/// never propagated out.
13110async fn accept_connections(
13111    endpoint: &str,
13112    call_home: Option<std::net::SocketAddr>,
13113    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13114    shared: Arc<NodeShared>,
13115) -> Result<(), NodeServerError> {
13116    let mut connections = JoinSet::new();
13117    let result = match call_home {
13118        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13119        Some(relay) => {
13120            let listener =
13121                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13122            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13123            tokio::pin!(listener);
13124            tokio::pin!(dialler);
13125            tokio::select! {
13126                result = &mut listener => result,
13127                result = &mut dialler => result,
13128            }
13129        }
13130    };
13131    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13132    while !connections.is_empty() {
13133        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13134        if remaining.is_zero()
13135            || timeout(remaining, connections.join_next()).await.is_err()
13136        {
13137            break;
13138        }
13139    }
13140    connections.abort_all();
13141    while connections.join_next().await.is_some() {}
13142    result
13143}
13144
13145/// Backoff ladder for a relay that will not answer, capped rather than
13146/// unbounded: a node whose relay is down is not broken and should keep
13147/// trying forever, but it should not spend a core doing it.
13148const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13149
13150/// Dials the relay, serves this wire over the connection, and does it
13151/// again when the connection ends.
13152///
13153/// The node is the SERVER on the socket it just opened. That inversion is
13154/// the whole point: `serve_connection` is the same function the local
13155/// listener feeds, and it neither knows nor cares which end placed the
13156/// call. The one thing this direction adds is the preface -- the relay
13157/// accepted a socket and cannot know whose it is, so the node says its
13158/// name before the handshake it would otherwise begin with.
13159///
13160/// Never returns an error. A relay that is down, refusing, or speaking a
13161/// different protocol is a condition to retry, not a reason to take the
13162/// node's local endpoint down with it; the only way out is shutdown.
13163async fn call_home_loop(
13164    relay: std::net::SocketAddr,
13165    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13166    shared: Arc<NodeShared>,
13167) -> Result<(), NodeServerError> {
13168    let mut failures = 0_usize;
13169    loop {
13170        if shared.shutdown.load(Ordering::Acquire) {
13171            return Ok(());
13172        }
13173        let permit = tokio::select! {
13174            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13175                permit.map_err(|_| NodeServerError::PreauthClosed)?
13176            }
13177            _ = shared.shutdown_notify.notified() => return Ok(()),
13178        };
13179        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13180            Ok(()) => {
13181                // A clean end is the relay hanging up, which it does on
13182                // its own restart. Reconnect immediately rather than
13183                // serving the backoff a peer that is plainly there.
13184                failures = 0;
13185                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13186            }
13187            Err(error) => {
13188                failures = failures.saturating_add(1);
13189                tracing::warn!(
13190                    node_id = %shared.node_id,
13191                    %relay,
13192                    cause = %error,
13193                    attempt = failures,
13194                    "call-home connection failed",
13195                );
13196                let backoff = call_home_backoff(failures);
13197                tokio::select! {
13198                    _ = sleep(backoff) => {}
13199                    _ = shared.shutdown_notify.notified() => return Ok(()),
13200                }
13201            }
13202        }
13203    }
13204}
13205
13206/// Idle time before the first keepalive probe on a call-home socket.
13207const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13208/// Interval between keepalive probes once the link has gone quiet.
13209const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13210
13211fn call_home_keepalive() -> socket2::TcpKeepalive {
13212    let keepalive = socket2::TcpKeepalive::new()
13213        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13214        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13215    #[cfg(not(windows))]
13216    let keepalive = keepalive.with_retries(3);
13217    keepalive
13218}
13219
13220fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13221    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13222}
13223
13224/// Same keepalive profile for any node->relay socket (restream uplink).
13225pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13226    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13227}
13228
13229/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13230fn call_home_backoff(attempt: usize) -> Duration {
13231    Duration::from_millis(
13232        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13233    )
13234}
13235
13236/// One call-home attempt: connect, name this node, then serve.
13237async fn dial_relay_and_serve(
13238    relay: std::net::SocketAddr,
13239    identity: Option<&hatchery_enroll::NodeIdentity>,
13240    shared: Arc<NodeShared>,
13241    preauth_permit: OwnedSemaphorePermit,
13242) -> Result<(), NodeServerError> {
13243    let mut stream = tokio::net::TcpStream::connect(relay)
13244        .await
13245        .map_err(NodeServerError::CallHomeTransport)?;
13246    // Nagle off for the same reason the rest of this wire wants it off:
13247    // the traffic is small request/response frames, and coalescing them
13248    // adds latency to every one.
13249    let _ = stream.set_nodelay(true);
13250    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13251    // relay-side socket kill whose RST was lost) would otherwise leave this
13252    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13253    // fails and the reconnect loop never runs. Kernel keepalive turns that
13254    // half-open link into a read error within a bounded time.
13255    if let Err(error) = enable_call_home_keepalive(&stream) {
13256        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13257    }
13258    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13259        .await
13260        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13261    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13262    let mut session_token = None;
13263    if let Some(identity) = identity {
13264        // An enrolled relay answers the announce with a signed-call-home challenge; a
13265        // token-mode relay sends its wire hello instead. Peek to tell them apart
13266        // without consuming either.
13267        let mut magic = [0_u8; 4];
13268        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13269            .await
13270            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13271            .map_err(NodeServerError::CallHomeTransport)?;
13272        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13273            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13274            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13275                .await
13276                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13277                .map_err(NodeServerError::CallHomeTransport)?;
13278            let (answer, token) = identity
13279                .answer_challenge(shared.node_id.as_str(), &frame)
13280                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13281            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13282            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13283            session_token = Some(token);
13284        }
13285    }
13286    serve_connection(stream, shared, session_token, preauth_permit).await
13287}
13288
13289async fn accept_connections_inner(
13290    endpoint: &str,
13291    shared: Arc<NodeShared>,
13292    connections: &mut JoinSet<Result<(), NodeServerError>>,
13293) -> Result<(), NodeServerError> {
13294    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13295    loop {
13296        while connections.try_join_next().is_some() {}
13297        if shared.shutdown.load(Ordering::Acquire) {
13298            return Ok(());
13299        }
13300        let preauth_permit = tokio::select! {
13301            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13302                permit.map_err(|_| NodeServerError::PreauthClosed)?
13303            }
13304            _ = shared.shutdown_notify.notified() => return Ok(()),
13305        };
13306        let server = tokio::select! {
13307            result = listener.accept() => result?,
13308            _ = shared.shutdown_notify.notified() => return Ok(()),
13309        };
13310        let connection_shared = Arc::clone(&shared);
13311        connections.spawn(async move {
13312            serve_connection(server, connection_shared, None, preauth_permit).await
13313        });
13314        if shared.shutdown.load(Ordering::Acquire) {
13315            return Ok(());
13316        }
13317    }
13318}
13319
13320/// Serves one connection, whichever end opened it.
13321///
13322/// Generic over the stream rather than typed to `LocalServerStream`
13323/// because a call-home connection arrives on a `TcpStream` the node itself
13324/// dialled. Nothing in the body ever depended on the concrete type -- it
13325/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13326/// the whole of what welding "who dials" to "who serves" was costing.
13327async fn serve_connection<S>(
13328    mut pipe: S,
13329    shared: Arc<NodeShared>,
13330    session_token: Option<String>,
13331    preauth_permit: OwnedSemaphorePermit,
13332) -> Result<(), NodeServerError>
13333where
13334    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13335{
13336    // A signed call-home connection authenticates with its own per-connection token.
13337    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13338    let hello_frame = timeout(
13339        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13340        read_json_frame_limited_body_timeout(
13341            &mut pipe,
13342            MAX_NODE_HELLO_FRAME_BYTES,
13343            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13344        ),
13345    )
13346    .await
13347    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13348    let ClientFrame::Hello(hello) = hello_frame else {
13349        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13350    };
13351    if hello.build_stamp != BUILD_STAMP {
13352        return Err(NodeServerError::Handshake(format!(
13353            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13354            hello.build_stamp,
13355        )));
13356    }
13357    let compatibility = match hello.compatibility.as_ref() {
13358        Some(offer) => {
13359            let mut selected = node_compatibility_support(&shared)?
13360                .negotiate(offer)
13361                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13362            project_negotiated_provider_ids(&mut selected);
13363            Some(selected)
13364        }
13365        None => None,
13366    };
13367    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13368    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13369        (Some(offer), Some(selected)) => negotiated_auth_proof(
13370            auth_token,
13371            AuthDirection::Server,
13372            hello.role,
13373            &hello.client_nonce,
13374            &server_nonce,
13375            offer,
13376            selected,
13377        ),
13378        (None, None) => auth_proof(
13379            auth_token,
13380            AuthDirection::Server,
13381            hello.role,
13382            &hello.client_nonce,
13383            &server_nonce,
13384        ),
13385        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13386    }
13387    .map_err(NodeServerError::Authentication)?;
13388    write_json_frame_limited(
13389        &mut pipe,
13390        &ServerFrame::Challenge(ServerChallenge {
13391            build_stamp: BUILD_STAMP.to_owned(),
13392            server_nonce,
13393            server_proof,
13394            compatibility: compatibility.clone(),
13395        }),
13396        MAX_NODE_HELLO_FRAME_BYTES,
13397    )
13398    .await?;
13399    let authentication = timeout(
13400        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13401        read_json_frame_limited_body_timeout(
13402            &mut pipe,
13403            MAX_NODE_HELLO_FRAME_BYTES,
13404            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13405        ),
13406    )
13407    .await
13408    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13409    let ClientFrame::Authenticate(authentication) = authentication else {
13410        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13411    };
13412    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13413        (Some(offer), Some(selected)) => negotiated_auth_proof(
13414            auth_token,
13415            AuthDirection::Client,
13416            hello.role,
13417            &hello.client_nonce,
13418            &server_nonce,
13419            offer,
13420            selected,
13421        ),
13422        (None, None) => auth_proof(
13423            auth_token,
13424            AuthDirection::Client,
13425            hello.role,
13426            &hello.client_nonce,
13427            &server_nonce,
13428        ),
13429        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13430    }
13431    .map_err(NodeServerError::Authentication)?;
13432    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13433        return Err(NodeServerError::Handshake("access denied".to_owned()));
13434    }
13435    let selected_capabilities = compatibility
13436        .as_ref()
13437        .map(|selected| selected.capabilities.clone())
13438        .unwrap_or_default();
13439    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13440        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13441    });
13442    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13443        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13444    });
13445    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13446        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13447    });
13448    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13449        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13450    });
13451    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13452        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13453    });
13454    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13455        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13456    }) && selected_capabilities.iter().any(|capability| {
13457        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13458    });
13459    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13460        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13461    });
13462    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13463        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13464    });
13465    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13466        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13467    });
13468    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13469        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13470    });
13471    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13472        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13473    });
13474    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13475        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13476    });
13477    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13478        .try_acquire_owned()
13479        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13480    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13481    let _release_guard = ControllerReleaseGuard {
13482        shared: Arc::clone(&shared),
13483        connection_id,
13484    };
13485    drop(preauth_permit);
13486    let _authenticated_permit = authenticated_permit;
13487    let mut event_rx = shared.event_tx.subscribe();
13488    let mut terminal_event_rx = terminal_event_subscription(
13489        &shared,
13490        include_terminal_frame_events,
13491    );
13492    let mut agent_stream_event_rx = agent_stream_event_subscription(
13493        &shared,
13494        include_agent_stream_events,
13495    );
13496    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13497        &shared,
13498        include_harness_mcp_proxy,
13499    );
13500    write_json_frame(
13501        &mut pipe,
13502        &ServerFrame::Hello(NodeHello {
13503            build_stamp: BUILD_STAMP.to_owned(),
13504            incarnation_id: shared.incarnation_id,
13505            connection_id,
13506            role: hello.role,
13507            event_sequence: shared.current_sequence(),
13508            controller: shared.controller_state(),
13509            snapshot: snapshot_for_wire(
13510                &shared,
13511                include_provider_runtime_status,
13512                include_open_provider_ids,
13513                include_spawn_profiles,
13514                include_managed_worktrees,
13515                include_child_environment_profiles,
13516                include_session_bundles,
13517                include_history_context_packs,
13518                include_agent_progress,
13519                include_session_task_correlation,
13520            ),
13521            compatibility,
13522        }),
13523    )
13524    .await?;
13525    let (mut reader, mut writer) = tokio::io::split(pipe);
13526    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13527    let reader_task = tokio::spawn(async move {
13528        loop {
13529            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13530                &mut reader,
13531                MAX_NODE_CLIENT_FRAME_BYTES,
13532                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13533            )
13534            .await;
13535            let terminal = frame.is_err();
13536            if frame_tx.send(frame).await.is_err() || terminal {
13537                break;
13538            }
13539        }
13540    });
13541    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13542    let mut pending_events = Vec::new();
13543    let mut resync_required = false;
13544    let mut discard_events_through = 0_u64;
13545    loop {
13546        // One tick of the serve loop, whether or not it had anything to do.
13547        // Against `connection_events_sent` this separates "busy" from
13548        // "spinning": a loop turning over thousands of times a second while
13549        // sending nothing is not serving, it is burning a core.
13550        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13551        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13552            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13553        for _ in 0..durable_drain_budget {
13554            match event_rx.try_recv() {
13555                Ok(event) => queue_connection_event(
13556                    &mut pending_events,
13557                    event,
13558                    discard_events_through,
13559                ),
13560                Err(broadcast::error::TryRecvError::Empty) => break,
13561                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13562                    resync_required = true;
13563                    break;
13564                }
13565                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13566            }
13567        }
13568        if let Some(receiver) = terminal_event_rx.as_mut() {
13569            match receiver.try_recv() {
13570                Ok(events) => queue_connection_event_batch(
13571                    &mut pending_events,
13572                    &events,
13573                    discard_events_through,
13574                ),
13575                Err(broadcast::error::TryRecvError::Empty) => {}
13576                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13577                    resync_required = true;
13578                }
13579                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13580            }
13581        }
13582        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13583            // Loops up to the same remaining-room budget the durable
13584            // `event_rx` drain above uses, rather than one `try_recv` per
13585            // tick: each successful receive here is itself only ONE
13586            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13587            // sends `vec![envelope]`, never more), so a single-shot drain
13588            // caps this connection's agent-stream throughput at one chunk
13589            // per outer-loop tick regardless of how many are already queued
13590            // -- exactly the kind of self-inflicted stall this crate's own
13591            // durable-event budget exists to avoid.
13592            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13593                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13594            for _ in 0..agent_stream_drain_budget {
13595                match receiver.try_recv() {
13596                    Ok(events) => queue_connection_event_batch(
13597                        &mut pending_events,
13598                        &events,
13599                        discard_events_through,
13600                    ),
13601                    Err(broadcast::error::TryRecvError::Empty) => break,
13602                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13603                        tracing::warn!(
13604                            connection_id,
13605                            dropped,
13606                            "agent stream event subscription lagged; forcing a resync",
13607                        );
13608                        resync_required = true;
13609                        break;
13610                    }
13611                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13612                }
13613            }
13614        }
13615        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13616            // Mirrors the agent-stream drain immediately above: each
13617            // successful receive is one single-envelope batch
13618            // (`publish_transient` always sends `vec![envelope]`), so this
13619            // loops up to the same remaining-room budget rather than
13620            // stopping after one `try_recv`.
13621            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13622                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13623            for _ in 0..harness_mcp_proxy_drain_budget {
13624                match receiver.try_recv() {
13625                    Ok(events) => queue_connection_event_batch(
13626                        &mut pending_events,
13627                        &events,
13628                        discard_events_through,
13629                    ),
13630                    Err(broadcast::error::TryRecvError::Empty) => break,
13631                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13632                        tracing::warn!(
13633                            connection_id,
13634                            dropped,
13635                            "harness MCP proxy event subscription lagged; forcing a resync",
13636                        );
13637                        resync_required = true;
13638                        break;
13639                    }
13640                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13641                }
13642            }
13643        }
13644        if resync_required {
13645            let event = resync_required_event(&shared);
13646            apply_connection_resync_watermark(
13647                &mut pending_events,
13648                &mut discard_events_through,
13649                event.sequence,
13650            );
13651            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13652            resync_required = false;
13653            continue;
13654        }
13655        if !pending_events.is_empty() {
13656            pending_events.sort_unstable_by_key(|event| event.sequence);
13657            let burst_len = connection_event_burst_len(pending_events.len());
13658            shared
13659                .connection_events_sent
13660                .fetch_add(burst_len as u64, Ordering::Relaxed);
13661            for event in pending_events.drain(..burst_len) {
13662                let event = if include_managed_worktrees {
13663                    Some(event)
13664                } else {
13665                    project_event_without_managed_worktrees(event)
13666                };
13667                let event = event.and_then(|event| if include_open_provider_ids {
13668                    Some(event)
13669                } else {
13670                    project_event_legacy_provider_ids(&shared, event)
13671                });
13672                let event = event.map(|event| if include_child_environment_profiles {
13673                    event
13674                } else {
13675                    project_event_without_child_environment_profile(event)
13676                });
13677                let event = event.map(|event| if include_session_bundles {
13678                    event
13679                } else {
13680                    project_event_without_session_bundle(event)
13681                });
13682                let event = event.map(|event| {
13683                    project_event_history_for_wire(event, include_history_context_packs)
13684                });
13685                let event = event.map(|event| if include_history_context_packs {
13686                    event
13687                } else {
13688                    project_event_without_context_pack(event)
13689                });
13690                let event = event.map(|event| if include_session_task_correlation {
13691                    event
13692                } else {
13693                    project_event_without_session_task_binding(event)
13694                });
13695                let exact_harness_controller = hello.role == ClientRole::Operator
13696                    && shared.controller_state().is_some_and(|controller| {
13697                        controller.connection_id == connection_id
13698                    });
13699                let event = event.and_then(|event| {
13700                    if include_harness_mcp_proxy && exact_harness_controller {
13701                        Some(event)
13702                    } else {
13703                        project_event_without_harness_mcp_proxy(event)
13704                    }
13705                });
13706                if let Some(event) = event {
13707                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13708                }
13709            }
13710        }
13711        tokio::select! {
13712            biased;
13713            frame = frame_rx.recv() => {
13714                let frame = match frame {
13715                    Some(frame) => frame,
13716                    None => break,
13717                };
13718                let frame = match frame {
13719                    Ok(frame) => frame,
13720                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13721                    Err(error) => return Err(error.into()),
13722                };
13723                let ClientFrame::Request(request) = frame else {
13724                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13725                };
13726                #[cfg(feature = "fixture")]
13727                let probe_spawn_managed_worktree_v2 = matches!(
13728                    &request.request,
13729                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13730                );
13731                let requires_open_provider_ids =
13732                    request_requires_open_provider_ids(&shared, &request.request);
13733                let requires_child_environment_profile =
13734                    request_requires_child_environment_profile(&shared, &request.request);
13735                let requires_session_bundle =
13736                    request_requires_session_bundle(&shared, &request.request);
13737                let requires_history_context_pack =
13738                    request_requires_history_context_pack(&shared, &request.request);
13739                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13740                    ResponseEnvelope {
13741                        request_id: request.request_id,
13742                        result: Err(failure(
13743                            NodeFailureCode::UnsupportedCapability,
13744                            "open provider IDs were not negotiated",
13745                        )),
13746                    }
13747                } else if requires_child_environment_profile
13748                    && !include_child_environment_profiles
13749                {
13750                    ResponseEnvelope {
13751                        request_id: request.request_id,
13752                        result: Err(failure(
13753                            NodeFailureCode::UnsupportedCapability,
13754                            "child environment profiles were not negotiated",
13755                        )),
13756                    }
13757                } else if requires_session_bundle && !include_session_bundles {
13758                    ResponseEnvelope {
13759                        request_id: request.request_id,
13760                        result: Err(failure(
13761                            NodeFailureCode::UnsupportedCapability,
13762                            "session bundles were not negotiated",
13763                        )),
13764                    }
13765                } else if requires_history_context_pack && !include_history_context_packs {
13766                    ResponseEnvelope {
13767                        request_id: request.request_id,
13768                        result: Err(failure(
13769                            NodeFailureCode::UnsupportedCapability,
13770                            "history context packs were not negotiated",
13771                        )),
13772                    }
13773                } else if request_uses_unnegotiated_capability(
13774                    &request.request,
13775                    &selected_capabilities,
13776                )
13777                {
13778                    ResponseEnvelope {
13779                        request_id: request.request_id,
13780                        result: Err(failure(
13781                            NodeFailureCode::UnsupportedCapability,
13782                            "request capability was not negotiated",
13783                        )),
13784                    }
13785                } else {
13786                    process_request(&shared, connection_id, hello.role, request).await
13787                };
13788                #[cfg(feature = "fixture")]
13789                if probe_spawn_managed_worktree_v2 {
13790                    shared
13791                        .fixture_spawn_managed_worktree_v2_failure_probe
13792                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13793                }
13794                if !include_provider_runtime_status {
13795                    clear_response_provider_runtime_status(&mut reply);
13796                }
13797                if !include_managed_worktrees {
13798                    project_response_without_managed_worktrees(&mut reply);
13799                }
13800                if !include_open_provider_ids {
13801                    project_response_legacy_provider_ids(&shared, &mut reply);
13802                }
13803                if !include_child_environment_profiles {
13804                    project_response_without_child_environment_profile(&mut reply);
13805                }
13806                if !include_session_bundles {
13807                    project_response_without_session_bundle(&mut reply);
13808                }
13809                project_response_history_for_wire(
13810                    &mut reply,
13811                    include_history_context_packs,
13812                );
13813                if !include_history_context_packs {
13814                    project_response_without_context_pack(&mut reply);
13815                }
13816                if !include_agent_progress {
13817                    project_response_without_agent_progress(&mut reply);
13818                }
13819                if !include_session_task_correlation {
13820                    project_response_without_session_task_binding(&mut reply);
13821                }
13822                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13823            }
13824            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13825            event = event_rx.recv() => {
13826                match event {
13827                    Ok(event) => queue_connection_event(
13828                        &mut pending_events,
13829                        event,
13830                        discard_events_through,
13831                    ),
13832                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13833                    Err(broadcast::error::RecvError::Closed) => break,
13834                }
13835            }
13836            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13837                match events {
13838                    Ok(events) => queue_connection_event_batch(
13839                        &mut pending_events,
13840                        &events,
13841                        discard_events_through,
13842                    ),
13843                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13844                    Err(broadcast::error::RecvError::Closed) => break,
13845                }
13846            }
13847            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13848                match events {
13849                    Ok(events) => queue_connection_event_batch(
13850                        &mut pending_events,
13851                        &events,
13852                        discard_events_through,
13853                    ),
13854                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13855                    Err(broadcast::error::RecvError::Closed) => break,
13856                }
13857            }
13858            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13859                match events {
13860                    Ok(events) => queue_connection_event_batch(
13861                        &mut pending_events,
13862                        &events,
13863                        discard_events_through,
13864                    ),
13865                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13866                    Err(broadcast::error::RecvError::Closed) => break,
13867                }
13868            }
13869            _ = shared.shutdown_notify.notified() => break,
13870        }
13871    }
13872    Ok(())
13873}
13874
13875async fn receive_terminal_event_batch(
13876    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13877) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13878    match receiver {
13879        Some(receiver) => receiver.recv().await,
13880        None => std::future::pending().await,
13881    }
13882}
13883
13884fn terminal_event_subscription(
13885    shared: &NodeShared,
13886    enabled: bool,
13887) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13888    enabled.then(|| shared.terminal_event_tx.subscribe())
13889}
13890
13891/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13892/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13893/// this needs a dedicated channel rather than a flag on the terminal one.
13894fn agent_stream_event_subscription(
13895    shared: &NodeShared,
13896    enabled: bool,
13897) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13898    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13899}
13900
13901/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13902/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13903/// own doc for why this needs a dedicated channel rather than a ride on
13904/// the durable `event_tx`.
13905fn harness_mcp_proxy_event_subscription(
13906    shared: &NodeShared,
13907    enabled: bool,
13908) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13909    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13910}
13911
13912fn queue_connection_event(
13913    pending: &mut Vec<NodeEventEnvelope>,
13914    event: NodeEventEnvelope,
13915    discard_through: u64,
13916) {
13917    if event.sequence <= discard_through
13918        && !event.event.requires_harness_mcp_proxy_capability()
13919    {
13920        return;
13921    }
13922    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13923        if let Some(index) = pending.iter().position(|current| {
13924            matches!(
13925                &current.event,
13926                NodeEvent::TerminalFrame {
13927                    address: current_address,
13928                    ..
13929                } if current_address == address
13930            )
13931        }) {
13932            pending.remove(index);
13933        }
13934    }
13935    pending.push(event);
13936}
13937
13938fn project_event_without_harness_mcp_proxy(
13939    envelope: NodeEventEnvelope,
13940) -> Option<NodeEventEnvelope> {
13941    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13942}
13943
13944fn queue_connection_event_batch(
13945    pending: &mut Vec<NodeEventEnvelope>,
13946    events: &[NodeEventEnvelope],
13947    discard_through: u64,
13948) {
13949    for event in events {
13950        queue_connection_event(pending, event.clone(), discard_through);
13951    }
13952}
13953
13954fn apply_connection_resync_watermark(
13955    pending: &mut Vec<NodeEventEnvelope>,
13956    discard_through: &mut u64,
13957    marker_sequence: u64,
13958) {
13959    *discard_through = (*discard_through).max(marker_sequence);
13960    pending.retain(|event| event.sequence > *discard_through);
13961}
13962
13963fn connection_event_burst_len(pending_len: usize) -> usize {
13964    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13965}
13966
13967fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13968    let history = shared
13969        .history
13970        .lock()
13971        .unwrap_or_else(|poisoned| poisoned.into_inner());
13972    let sequence = history.last_sequence;
13973    let oldest_available_sequence = history.replay_floor_sequence;
13974    NodeEventEnvelope {
13975        sequence,
13976        event: NodeEvent::ResyncRequired {
13977            oldest_available_sequence,
13978        },
13979    }
13980}
13981
13982fn node_compatibility_support(
13983    shared: &NodeShared,
13984) -> Result<NodeCompatibilitySupport, NodeServerError> {
13985    let mut support = node_compatibility_support_for_manifest(
13986        &shared.provider_contracts,
13987        &shared.provider_adapter_contracts,
13988    )?;
13989    if shared
13990        .delivery_store
13991        .lock()
13992        .unwrap_or_else(|poisoned| poisoned.into_inner())
13993        .is_some()
13994    {
13995        support.capabilities.push(
13996            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13997                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13998        );
13999    }
14000    if shared.harness_mcp_registry.is_some() {
14001        support.capabilities.push(
14002            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14003                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14004        );
14005    }
14006    Ok(support)
14007}
14008
14009fn node_compatibility_support_for_manifest(
14010    provider_contracts: &[ProviderContractSupport],
14011    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14012) -> Result<NodeCompatibilitySupport, NodeServerError> {
14013    Ok(NodeCompatibilitySupport {
14014        build_stamp: BUILD_STAMP.to_owned(),
14015        capabilities: baseline_capabilities()?,
14016        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14017        path_semantics: platform::path_semantics(),
14018        local_transport: platform::local_transport(),
14019        state_schema: StateSchemaSupport {
14020            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14021                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14022        },
14023        provider_contracts: provider_contracts.to_vec(),
14024        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14025    })
14026}
14027
14028fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14029    let capabilities = [
14030        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14031        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14032        NODE_REPOSITORY_PATH_CAPABILITY,
14033        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14034        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14035        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14036        NODE_GIT_READ_CAPABILITY,
14037        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14038        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14039        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14040        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14041        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14042        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14043        NODE_ACP_CONTROL_CAPABILITY,
14044        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14045        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14046        NODE_WORKTREE_SELECTION_CAPABILITY,
14047        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14048        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14049        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14050        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14051        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14052        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14053        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14054        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14055        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14056        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14057        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14058        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14059    ]
14060        .into_iter()
14061        .map(|capability| {
14062            CapabilityId::new(capability)
14063                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14064        })
14065        .collect::<Result<Vec<_>, _>>()?;
14066    #[cfg(windows)]
14067    let capabilities = {
14068        let mut capabilities = capabilities;
14069        capabilities.push(
14070            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14071                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14072        );
14073        capabilities
14074    };
14075    Ok(capabilities)
14076}
14077
14078fn request_uses_unnegotiated_capability(
14079    request: &NodeRequest,
14080    selected_capabilities: &[CapabilityId],
14081) -> bool {
14082    let selected = |required: &str| {
14083        selected_capabilities
14084            .iter()
14085            .any(|capability| capability.as_str() == required)
14086    };
14087    request
14088        .required_capability()
14089        .is_some_and(|required| !selected(required))
14090        || (request.requires_worktree_selection_capability()
14091            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14092        || (request.requires_spawn_spec_defaults_overrides_capability()
14093            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14094        || (request.requires_spawn_profile_revision_capability()
14095            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14096        || (request.requires_child_environment_profile_capability()
14097            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14098        || (request.requires_session_bundle_materialization_capability()
14099            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14100        || (request.requires_history_context_pack_capability()
14101            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14102}
14103
14104fn snapshot_for_wire(
14105    shared: &NodeShared,
14106    include_provider_runtime_status: bool,
14107    include_open_provider_ids: bool,
14108    include_spawn_profiles: bool,
14109    include_managed_worktrees: bool,
14110    include_child_environment_profiles: bool,
14111    include_session_bundles: bool,
14112    include_history_context_packs: bool,
14113    include_agent_progress: bool,
14114    include_session_task_correlation: bool,
14115) -> NodeSnapshot {
14116    let mut snapshot = shared.snapshot();
14117    if !include_provider_runtime_status {
14118        snapshot.provider_runtime_statuses.clear();
14119    }
14120    if !include_open_provider_ids {
14121        project_snapshot_legacy_provider_ids(&mut snapshot);
14122    }
14123    if !include_managed_worktrees {
14124        snapshot.managed_worktrees.clear();
14125        for workspace in &mut snapshot.workspaces {
14126            workspace.managed_worktree_profiles = None;
14127        }
14128    }
14129    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14130        if !include_spawn_profiles {
14131            inventory.spawn_profiles = None;
14132        }
14133        if !include_session_bundles {
14134            inventory.bundles = None;
14135        }
14136        if inventory.spawn_profiles.is_none()
14137            && inventory.bundles.is_none()
14138            && inventory.network_allowlists.is_none()
14139        {
14140            snapshot.launch_inventory = None;
14141        }
14142    }
14143    if !include_child_environment_profiles {
14144        clear_snapshot_child_environment_profiles(&mut snapshot);
14145    }
14146    if !include_session_bundles {
14147        clear_snapshot_session_bundles(&mut snapshot);
14148    }
14149    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14150    if !include_history_context_packs {
14151        clear_snapshot_context_packs(&mut snapshot);
14152    }
14153    if !include_agent_progress {
14154        snapshot.agent_progress.clear();
14155    }
14156    if !include_session_task_correlation {
14157        clear_snapshot_session_task_bindings(&mut snapshot);
14158    }
14159    snapshot
14160}
14161
14162fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14163    for record in &mut snapshot.session_records {
14164        record.task_binding = None;
14165    }
14166}
14167
14168fn project_event_without_session_task_binding(
14169    mut envelope: NodeEventEnvelope,
14170) -> NodeEventEnvelope {
14171    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14172        record.task_binding = None;
14173    }
14174    envelope
14175}
14176
14177fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14178    let Ok(response) = reply.result.as_mut() else { return };
14179    match response {
14180        NodeResponse::Snapshot { snapshot, .. } => {
14181            clear_snapshot_session_task_bindings(snapshot);
14182        }
14183        NodeResponse::Resync { snapshot, events, .. } => {
14184            clear_snapshot_session_task_bindings(snapshot);
14185            for event in events {
14186                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14187                    record.task_binding = None;
14188                }
14189            }
14190        }
14191        NodeResponse::SessionRecordUpdated { record }
14192        | NodeResponse::ProviderSessionIndexed { record }
14193        | NodeResponse::NativeSessionIndexed { record, .. }
14194        | NodeResponse::SessionRecordResumed { record, .. } => {
14195            record.task_binding = None;
14196        }
14197        _ => {}
14198    }
14199}
14200
14201fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14202    let Ok(response) = reply.result.as_mut() else { return };
14203    match response {
14204        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14205            snapshot.agent_progress.clear();
14206        }
14207        _ => {}
14208    }
14209}
14210
14211fn project_event_without_managed_worktrees(
14212    mut envelope: NodeEventEnvelope,
14213) -> Option<NodeEventEnvelope> {
14214    if matches!(
14215        envelope.event,
14216        NodeEvent::ManagedWorktreeUpserted { .. }
14217            | NodeEvent::ManagedWorktreeRemoved { .. }
14218    ) {
14219        return None;
14220    }
14221    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14222        workspace.managed_worktree_profiles = None;
14223    }
14224    Some(envelope)
14225}
14226
14227fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14228    if matches!(
14229        reply.result,
14230        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14231            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14232    ) {
14233        reply.result = Err(failure(
14234            NodeFailureCode::UnsupportedCapability,
14235            "managed worktree capability was not negotiated",
14236        ));
14237        return;
14238    }
14239    let Ok(response) = reply.result.as_mut() else { return };
14240    match response {
14241        NodeResponse::Snapshot { snapshot, .. } => {
14242            snapshot.managed_worktrees.clear();
14243            for workspace in &mut snapshot.workspaces {
14244                workspace.managed_worktree_profiles = None;
14245            }
14246        }
14247        NodeResponse::Resync { snapshot, events, .. } => {
14248            snapshot.managed_worktrees.clear();
14249            for workspace in &mut snapshot.workspaces {
14250                workspace.managed_worktree_profiles = None;
14251            }
14252            *events = std::mem::take(events)
14253                .into_iter()
14254                .filter_map(project_event_without_managed_worktrees)
14255                .collect();
14256        }
14257        NodeResponse::WorkspaceRegistered { workspace }
14258        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14259        | NodeResponse::WorktreeCreated { workspace, .. } => {
14260            workspace.managed_worktree_profiles = None;
14261        }
14262        NodeResponse::WorkspaceInspected { inspection } => {
14263            inspection.git.managed_worktree = None;
14264        }
14265        _ => {}
14266    }
14267}
14268
14269fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14270    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14271        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14272    });
14273    if includes_open_provider_ids {
14274        return;
14275    }
14276    compatibility
14277        .provider_contracts
14278        .retain(|contract| provider_id_is_legacy(&contract.provider));
14279    compatibility
14280        .provider_adapter_contracts
14281        .retain(|contract| provider_id_is_legacy(&contract.provider));
14282}
14283
14284fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14285    if let Some(spec) = match request {
14286        NodeRequest::SpawnSpec { spec }
14287        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14288        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14289        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14290        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14291        _ => None,
14292    } {
14293        return shared
14294            .resolve_spawn_spec(spec)
14295            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14296    }
14297    request_requires_open_provider_ids_with(
14298        request,
14299        |session| shared.validate_address(session).ok(),
14300        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14301    )
14302}
14303
14304fn request_requires_child_environment_profile(
14305    shared: &NodeShared,
14306    request: &NodeRequest,
14307) -> bool {
14308    if let Some(spec) = match request {
14309        NodeRequest::SpawnSpec { spec }
14310        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14311        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14312        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14313        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14314        _ => None,
14315    } {
14316        return shared
14317            .resolve_spawn_spec(spec)
14318            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14319    }
14320    match request {
14321        NodeRequest::Resume { session, .. }
14322        | NodeRequest::Prompt { session, .. }
14323        | NodeRequest::Paste { session, .. }
14324        | NodeRequest::Input { session, .. }
14325        | NodeRequest::TerminalBytes { session, .. }
14326        | NodeRequest::TerminalControl { session, .. }
14327        | NodeRequest::Resize { session, .. }
14328        | NodeRequest::Interrupt { session }
14329        | NodeRequest::Stop { session, .. }
14330        | NodeRequest::Remove { session }
14331        | NodeRequest::ResolveInteraction { session, .. }
14332        | NodeRequest::SetSessionMode { session, .. }
14333        | NodeRequest::SetSessionConfigOption { session, .. }
14334        | NodeRequest::SetSessionModel { session, .. }
14335        | NodeRequest::DiscoverHistory { session, .. }
14336        | NodeRequest::LoadHistory { session, .. }
14337        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14338        | NodeRequest::ExportContextPack { session } => shared
14339            .session_bindings
14340            .lock()
14341            .unwrap_or_else(|poisoned| poisoned.into_inner())
14342            .get(&session.session.instance_id)
14343            .filter(|binding| {
14344                binding.workspace_id == session.workspace_id
14345                    && binding.generation == session.session.generation
14346            })
14347            .map_or(true, |binding| binding.environment_profile.is_some()),
14348        NodeRequest::RenameSessionRecord { record_id, .. }
14349        | NodeRequest::SetSessionTask { record_id, .. }
14350        | NodeRequest::ResumeSessionRecord { record_id, .. }
14351        | NodeRequest::ForgetSessionRecord { record_id } => shared
14352            .record(record_id)
14353            .map_or(true, |record| record.environment_profile.is_some()),
14354        NodeRequest::ArmHarnessMcpReservation { .. }
14355        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14356        | NodeRequest::ActivateHarnessMcpReservation { .. }
14357        | NodeRequest::AbortHarnessMcpReservation { .. }
14358        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14359        | NodeRequest::RejectHarnessMcpCall { .. }
14360        | NodeRequest::Snapshot
14361        | NodeRequest::Resync { .. }
14362        | NodeRequest::BeginDeliveryStage { .. }
14363        | NodeRequest::PutDeliveryBlobChunk { .. }
14364        | NodeRequest::CommitDeliveryStage { .. }
14365        | NodeRequest::AbortDeliveryStage { .. }
14366        | NodeRequest::BrowseHostDirectories { .. }
14367        | NodeRequest::InspectWorkspace { .. }
14368        | NodeRequest::ReadWorkspaceFile { .. }
14369        | NodeRequest::WriteWorkspaceFile { .. }
14370        | NodeRequest::CreateWorkspaceFile { .. }
14371        | NodeRequest::CreateWorkspaceDirectory { .. }
14372        | NodeRequest::ReadGitHistory { .. }
14373        | NodeRequest::ReadGitDiff { .. }
14374        | NodeRequest::AcquireController { .. }
14375        | NodeRequest::ReleaseController
14376        | NodeRequest::Spawn { .. }
14377        | NodeRequest::SpawnWithOptions { .. }
14378        | NodeRequest::SpawnSpec { .. }
14379        | NodeRequest::RegisterWorkspace { .. }
14380        | NodeRequest::CreateStandaloneWorkspace { .. }
14381        | NodeRequest::UnregisterWorkspace { .. }
14382        | NodeRequest::CreateWorktree { .. }
14383        | NodeRequest::RemoveWorktree { .. }
14384        | NodeRequest::SpawnManagedWorktree { .. }
14385        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14386        | NodeRequest::CleanupManagedWorktree { .. }
14387        | NodeRequest::IndexProviderSession { .. }
14388        | NodeRequest::IndexNativeSession { .. }
14389        | NodeRequest::CatalogNativeSessions { .. }
14390        | NodeRequest::PageNativeSessions { .. }
14391        | NodeRequest::PreviewNativeSession { .. }
14392        | NodeRequest::PreviewSessionRecord { .. }
14393        | NodeRequest::ForgetContextPack { .. }
14394        | NodeRequest::ResolveDurableContextPack { .. }
14395        | NodeRequest::ReadContextPack { .. }
14396        | NodeRequest::Shutdown => false,
14397    }
14398}
14399
14400fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14401    if let Some(spec) = match request {
14402        NodeRequest::SpawnSpec { spec }
14403        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14404        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14405        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14406        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14407        _ => None,
14408    } {
14409        return shared
14410            .resolve_spawn_spec(spec)
14411            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14412    }
14413    match request {
14414        NodeRequest::Resume { session, .. }
14415        | NodeRequest::Prompt { session, .. }
14416        | NodeRequest::Paste { session, .. }
14417        | NodeRequest::Input { session, .. }
14418        | NodeRequest::TerminalBytes { session, .. }
14419        | NodeRequest::TerminalControl { session, .. }
14420        | NodeRequest::Resize { session, .. }
14421        | NodeRequest::Interrupt { session }
14422        | NodeRequest::Stop { session, .. }
14423        | NodeRequest::Remove { session }
14424        | NodeRequest::ResolveInteraction { session, .. }
14425        | NodeRequest::SetSessionMode { session, .. }
14426        | NodeRequest::SetSessionConfigOption { session, .. }
14427        | NodeRequest::SetSessionModel { session, .. }
14428        | NodeRequest::DiscoverHistory { session, .. }
14429        | NodeRequest::LoadHistory { session, .. }
14430        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14431        | NodeRequest::ExportContextPack { session } => shared
14432            .session_bindings
14433            .lock()
14434            .unwrap_or_else(|poisoned| poisoned.into_inner())
14435            .get(&session.session.instance_id)
14436            .filter(|binding| {
14437                binding.workspace_id == session.workspace_id
14438                    && binding.generation == session.session.generation
14439            })
14440            .map_or(true, |binding| binding.bundle.is_some()),
14441        NodeRequest::RenameSessionRecord { record_id, .. }
14442        | NodeRequest::SetSessionTask { record_id, .. }
14443        | NodeRequest::ResumeSessionRecord { record_id, .. }
14444        | NodeRequest::ForgetSessionRecord { record_id } => shared
14445            .record(record_id)
14446            .map_or(true, |record| record.bundle.is_some()),
14447        NodeRequest::ArmHarnessMcpReservation { .. }
14448        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14449        | NodeRequest::ActivateHarnessMcpReservation { .. }
14450        | NodeRequest::AbortHarnessMcpReservation { .. }
14451        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14452        | NodeRequest::RejectHarnessMcpCall { .. }
14453        | NodeRequest::Snapshot
14454        | NodeRequest::Resync { .. }
14455        | NodeRequest::BeginDeliveryStage { .. }
14456        | NodeRequest::PutDeliveryBlobChunk { .. }
14457        | NodeRequest::CommitDeliveryStage { .. }
14458        | NodeRequest::AbortDeliveryStage { .. }
14459        | NodeRequest::BrowseHostDirectories { .. }
14460        | NodeRequest::InspectWorkspace { .. }
14461        | NodeRequest::ReadWorkspaceFile { .. }
14462        | NodeRequest::WriteWorkspaceFile { .. }
14463        | NodeRequest::CreateWorkspaceFile { .. }
14464        | NodeRequest::CreateWorkspaceDirectory { .. }
14465        | NodeRequest::ReadGitHistory { .. }
14466        | NodeRequest::ReadGitDiff { .. }
14467        | NodeRequest::AcquireController { .. }
14468        | NodeRequest::ReleaseController
14469        | NodeRequest::Spawn { .. }
14470        | NodeRequest::SpawnWithOptions { .. }
14471        | NodeRequest::SpawnSpec { .. }
14472        | NodeRequest::RegisterWorkspace { .. }
14473        | NodeRequest::CreateStandaloneWorkspace { .. }
14474        | NodeRequest::UnregisterWorkspace { .. }
14475        | NodeRequest::CreateWorktree { .. }
14476        | NodeRequest::RemoveWorktree { .. }
14477        | NodeRequest::SpawnManagedWorktree { .. }
14478        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14479        | NodeRequest::CleanupManagedWorktree { .. }
14480        | NodeRequest::IndexProviderSession { .. }
14481        | NodeRequest::IndexNativeSession { .. }
14482        | NodeRequest::CatalogNativeSessions { .. }
14483        | NodeRequest::PageNativeSessions { .. }
14484        | NodeRequest::PreviewNativeSession { .. }
14485        | NodeRequest::PreviewSessionRecord { .. }
14486        | NodeRequest::ForgetContextPack { .. }
14487        | NodeRequest::ResolveDurableContextPack { .. }
14488        | NodeRequest::ReadContextPack { .. }
14489        | NodeRequest::Shutdown => false,
14490    }
14491}
14492
14493fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14494    if request.requires_history_context_pack_capability() {
14495        return true;
14496    }
14497    if let Some(spec) = match request {
14498        NodeRequest::SpawnSpec { spec }
14499        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14500        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14501        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14502        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14503        _ => None,
14504    } {
14505        return shared
14506            .resolve_spawn_spec(spec)
14507            .map_or(true, |resolved| resolved.context_id.is_some());
14508    }
14509    match request {
14510        NodeRequest::Resume { session, .. }
14511        | NodeRequest::Prompt { session, .. }
14512        | NodeRequest::Paste { session, .. }
14513        | NodeRequest::Input { session, .. }
14514        | NodeRequest::TerminalBytes { session, .. }
14515        | NodeRequest::TerminalControl { session, .. }
14516        | NodeRequest::Resize { session, .. }
14517        | NodeRequest::Interrupt { session }
14518        | NodeRequest::Stop { session, .. }
14519        | NodeRequest::Remove { session }
14520        | NodeRequest::ResolveInteraction { session, .. }
14521        | NodeRequest::SetSessionMode { session, .. }
14522        | NodeRequest::SetSessionConfigOption { session, .. }
14523        | NodeRequest::SetSessionModel { session, .. } => shared
14524            .session_bindings
14525            .lock()
14526            .unwrap_or_else(|poisoned| poisoned.into_inner())
14527            .get(&session.session.instance_id)
14528            .filter(|binding| {
14529                binding.workspace_id == session.workspace_id
14530                    && binding.generation == session.session.generation
14531            })
14532            .map_or(true, |binding| binding.context.is_some()),
14533        NodeRequest::RenameSessionRecord { record_id, .. }
14534        | NodeRequest::SetSessionTask { record_id, .. }
14535        | NodeRequest::ResumeSessionRecord { record_id, .. }
14536        | NodeRequest::ForgetSessionRecord { record_id } => shared
14537            .record(record_id)
14538            .map_or(true, |record| record.context.is_some()),
14539        NodeRequest::ArmHarnessMcpReservation { .. }
14540        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14541        | NodeRequest::ActivateHarnessMcpReservation { .. }
14542        | NodeRequest::AbortHarnessMcpReservation { .. }
14543        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14544        | NodeRequest::RejectHarnessMcpCall { .. }
14545        | NodeRequest::Snapshot
14546        | NodeRequest::Resync { .. }
14547        | NodeRequest::BeginDeliveryStage { .. }
14548        | NodeRequest::PutDeliveryBlobChunk { .. }
14549        | NodeRequest::CommitDeliveryStage { .. }
14550        | NodeRequest::AbortDeliveryStage { .. }
14551        | NodeRequest::BrowseHostDirectories { .. }
14552        | NodeRequest::InspectWorkspace { .. }
14553        | NodeRequest::ReadWorkspaceFile { .. }
14554        | NodeRequest::WriteWorkspaceFile { .. }
14555        | NodeRequest::CreateWorkspaceFile { .. }
14556        | NodeRequest::CreateWorkspaceDirectory { .. }
14557        | NodeRequest::ReadGitHistory { .. }
14558        | NodeRequest::ReadGitDiff { .. }
14559        | NodeRequest::AcquireController { .. }
14560        | NodeRequest::ReleaseController
14561        | NodeRequest::Spawn { .. }
14562        | NodeRequest::SpawnWithOptions { .. }
14563        | NodeRequest::SpawnSpec { .. }
14564        | NodeRequest::RegisterWorkspace { .. }
14565        | NodeRequest::CreateStandaloneWorkspace { .. }
14566        | NodeRequest::UnregisterWorkspace { .. }
14567        | NodeRequest::CreateWorktree { .. }
14568        | NodeRequest::RemoveWorktree { .. }
14569        | NodeRequest::SpawnManagedWorktree { .. }
14570        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14571        | NodeRequest::CleanupManagedWorktree { .. }
14572        | NodeRequest::IndexProviderSession { .. }
14573        | NodeRequest::IndexNativeSession { .. }
14574        | NodeRequest::CatalogNativeSessions { .. }
14575        | NodeRequest::PageNativeSessions { .. }
14576        | NodeRequest::PreviewNativeSession { .. }
14577        | NodeRequest::PreviewSessionRecord { .. }
14578        | NodeRequest::DiscoverHistory { .. }
14579        | NodeRequest::LoadHistory { .. }
14580        | NodeRequest::ExportContextPackForSessionRecord { .. }
14581        | NodeRequest::ExportContextPack { .. }
14582        | NodeRequest::ForgetContextPack { .. }
14583        | NodeRequest::ResolveDurableContextPack { .. }
14584        | NodeRequest::ReadContextPack { .. }
14585        | NodeRequest::Shutdown => false,
14586    }
14587}
14588
14589fn request_requires_open_provider_ids_with(
14590    request: &NodeRequest,
14591    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14592    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14593) -> bool {
14594    match request {
14595        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14596        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14597        NodeRequest::CatalogNativeSessions { route, .. }
14598        | NodeRequest::PageNativeSessions { route, .. } => {
14599            !provider_id_is_legacy(&route.provider)
14600        }
14601        NodeRequest::PreviewNativeSession { selection, .. }
14602        | NodeRequest::IndexNativeSession { selection, .. } => {
14603            !provider_id_is_legacy(&selection.route.provider)
14604        }
14605        NodeRequest::SpawnSpec { .. }
14606        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14607        NodeRequest::SpawnManagedWorktree { .. }
14608        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14609        NodeRequest::Resume { session, .. }
14610        | NodeRequest::Prompt { session, .. }
14611        | NodeRequest::Paste { session, .. }
14612        | NodeRequest::Input { session, .. }
14613        | NodeRequest::TerminalBytes { session, .. }
14614        | NodeRequest::TerminalControl { session, .. }
14615        | NodeRequest::Resize { session, .. }
14616        | NodeRequest::Interrupt { session }
14617        | NodeRequest::Stop { session, .. }
14618        | NodeRequest::Remove { session }
14619        | NodeRequest::ResolveInteraction { session, .. }
14620        | NodeRequest::SetSessionMode { session, .. }
14621        | NodeRequest::SetSessionConfigOption { session, .. }
14622        | NodeRequest::SetSessionModel { session, .. }
14623        | NodeRequest::DiscoverHistory { session, .. }
14624        | NodeRequest::LoadHistory { session, .. }
14625        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14626        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14627            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14628        NodeRequest::RenameSessionRecord { record_id, .. }
14629        | NodeRequest::SetSessionTask { record_id, .. }
14630        | NodeRequest::ResumeSessionRecord { record_id, .. }
14631        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14632            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14633        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14634            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14635        NodeRequest::ArmHarnessMcpReservation { .. }
14636        | NodeRequest::ActivateHarnessMcpReservation { .. }
14637        | NodeRequest::AbortHarnessMcpReservation { .. }
14638        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14639        | NodeRequest::RejectHarnessMcpCall { .. }
14640        | NodeRequest::Snapshot
14641        | NodeRequest::Resync { .. }
14642        | NodeRequest::BeginDeliveryStage { .. }
14643        | NodeRequest::PutDeliveryBlobChunk { .. }
14644        | NodeRequest::CommitDeliveryStage { .. }
14645        | NodeRequest::AbortDeliveryStage { .. }
14646        | NodeRequest::BrowseHostDirectories { .. }
14647        | NodeRequest::InspectWorkspace { .. }
14648        | NodeRequest::ReadWorkspaceFile { .. }
14649        | NodeRequest::WriteWorkspaceFile { .. }
14650        | NodeRequest::CreateWorkspaceFile { .. }
14651        | NodeRequest::CreateWorkspaceDirectory { .. }
14652        | NodeRequest::ReadGitHistory { .. }
14653        | NodeRequest::ReadGitDiff { .. }
14654        | NodeRequest::AcquireController { .. }
14655        | NodeRequest::ReleaseController
14656        | NodeRequest::RegisterWorkspace { .. }
14657        | NodeRequest::CreateStandaloneWorkspace { .. }
14658        | NodeRequest::UnregisterWorkspace { .. }
14659        | NodeRequest::CreateWorktree { .. }
14660        | NodeRequest::RemoveWorktree { .. }
14661        | NodeRequest::CleanupManagedWorktree { .. }
14662        | NodeRequest::Shutdown => false,
14663        NodeRequest::ForgetContextPack { .. } => true,
14664        // No session/record to cheaply resolve a provider from here either;
14665        // the resolved receipt can carry an open-id provider, so require the
14666        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14667        // is the same digest-keyed lookup one step further into the same
14668        // catalog, so it inherits the same conservative answer.
14669        NodeRequest::ResolveDurableContextPack { .. }
14670        | NodeRequest::ReadContextPack { .. } => true,
14671    }
14672}
14673
14674fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14675    snapshot.enabled_providers.retain(provider_id_is_legacy);
14676    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14677        snapshot
14678            .provider_runtime_statuses
14679            .as_slice()
14680            .iter()
14681            .filter(|status| provider_id_is_legacy(status.provider()))
14682            .cloned(),
14683    )
14684    .expect("a filtered provider runtime projection remains valid");
14685    snapshot
14686        .session_records
14687        .retain(|record| provider_id_is_legacy(&record.provider));
14688    for workspace in &mut snapshot.workspaces {
14689        workspace
14690            .sessions
14691            .retain(|session| provider_id_is_legacy(&session.agent_id));
14692    }
14693}
14694
14695fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14696    for record in &mut snapshot.session_records {
14697        record.environment_profile = None;
14698    }
14699    if let Some(profiles) = snapshot
14700        .launch_inventory
14701        .as_mut()
14702        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14703    {
14704        for profile in profiles {
14705            profile.environment_profile = None;
14706        }
14707    }
14708}
14709
14710fn project_event_without_child_environment_profile(
14711    mut envelope: NodeEventEnvelope,
14712) -> NodeEventEnvelope {
14713    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14714        record.environment_profile = None;
14715    }
14716    envelope
14717}
14718
14719fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14720    let contains_environment_profile = match reply.result.as_ref() {
14721        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14722            receipt.environment_profile.is_some()
14723        }
14724        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14725            receipt.spawn.environment_profile.is_some()
14726        }
14727        _ => false,
14728    };
14729    if contains_environment_profile {
14730        reply.result = Err(failure(
14731            NodeFailureCode::UnsupportedCapability,
14732            "child environment profiles were not negotiated",
14733        ));
14734        return;
14735    }
14736    let Ok(response) = reply.result.as_mut() else {
14737        return;
14738    };
14739    match response {
14740        NodeResponse::Snapshot { snapshot, .. } => {
14741            clear_snapshot_child_environment_profiles(snapshot);
14742        }
14743        NodeResponse::Resync { snapshot, events, .. } => {
14744            clear_snapshot_child_environment_profiles(snapshot);
14745            for event in events {
14746                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14747                    record.environment_profile = None;
14748                }
14749            }
14750        }
14751        NodeResponse::SessionRecordUpdated { record }
14752        | NodeResponse::ProviderSessionIndexed { record }
14753        | NodeResponse::NativeSessionIndexed { record, .. }
14754        | NodeResponse::SessionRecordResumed { record, .. } => {
14755            record.environment_profile = None;
14756        }
14757        NodeResponse::Armed { .. }
14758        | NodeResponse::Spawned { .. }
14759        | NodeResponse::Activated { .. }
14760        | NodeResponse::Aborted { .. }
14761        | NodeResponse::ReplyChunkAccepted { .. }
14762        | NodeResponse::CallRejected { .. }
14763        | NodeResponse::DeliveryStageBegun { .. }
14764        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14765        | NodeResponse::DeliveryCommitted { .. }
14766        | NodeResponse::DeliveryStageAborted { .. }
14767        | NodeResponse::WorkspaceInspected { .. }
14768        | NodeResponse::HostDirectoriesBrowsed { .. }
14769        | NodeResponse::WorkspaceFileRead { .. }
14770        | NodeResponse::WorkspaceFileWritten { .. }
14771        | NodeResponse::WorkspaceFileCreated { .. }
14772        | NodeResponse::WorkspaceDirectoryCreated { .. }
14773        | NodeResponse::GitHistoryRead { .. }
14774        | NodeResponse::GitDiffRead { .. }
14775        | NodeResponse::Controller { .. }
14776        | NodeResponse::SpawnAccepted { .. }
14777        | NodeResponse::SpawnSpecAccepted { .. }
14778        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14779        | NodeResponse::ManagedWorktreeCleanup { .. }
14780        | NodeResponse::NativeSessionsCataloged { .. }
14781        | NodeResponse::NativeSessionsPaged { .. }
14782        | NodeResponse::NativeSessionPreviewed { .. }
14783        | NodeResponse::SessionRecordPreviewed { .. }
14784        | NodeResponse::HistoryDiscovered { .. }
14785        | NodeResponse::HistoryLoaded { .. }
14786        | NodeResponse::ContextPackForSessionRecordExported { .. }
14787        | NodeResponse::ContextPackExported { .. }
14788        | NodeResponse::ContextPackForgotten { .. }
14789        | NodeResponse::DurableContextPackResolved { .. }
14790        | NodeResponse::ContextPackBytesRead { .. }
14791        | NodeResponse::SessionRecordForgotten { .. }
14792        | NodeResponse::WorkspaceRegistered { .. }
14793        | NodeResponse::StandaloneWorkspaceCreated { .. }
14794        | NodeResponse::WorkspaceUnregistered { .. }
14795        | NodeResponse::WorktreeCreated { .. }
14796        | NodeResponse::WorktreeRemoved { .. }
14797        | NodeResponse::Accepted
14798        | NodeResponse::ShuttingDown => {}
14799    }
14800}
14801
14802fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14803    for record in &mut snapshot.session_records {
14804        record.bundle = None;
14805    }
14806}
14807
14808fn project_event_without_session_bundle(
14809    mut envelope: NodeEventEnvelope,
14810) -> NodeEventEnvelope {
14811    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14812        record.bundle = None;
14813    }
14814    envelope
14815}
14816
14817fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14818    let contains_bundle = match reply.result.as_ref() {
14819        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14820        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14821            receipt.spawn.bundle.is_some()
14822        }
14823        _ => false,
14824    };
14825    if contains_bundle {
14826        reply.result = Err(failure(
14827            NodeFailureCode::UnsupportedCapability,
14828            "session bundles were not negotiated",
14829        ));
14830        return;
14831    }
14832    let Ok(response) = reply.result.as_mut() else {
14833        return;
14834    };
14835    match response {
14836        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14837        NodeResponse::Resync { snapshot, events, .. } => {
14838            clear_snapshot_session_bundles(snapshot);
14839            for event in events {
14840                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14841                    record.bundle = None;
14842                }
14843            }
14844        }
14845        NodeResponse::SessionRecordUpdated { record }
14846        | NodeResponse::ProviderSessionIndexed { record }
14847        | NodeResponse::NativeSessionIndexed { record, .. }
14848        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14849        NodeResponse::Armed { .. }
14850        | NodeResponse::Spawned { .. }
14851        | NodeResponse::Activated { .. }
14852        | NodeResponse::Aborted { .. }
14853        | NodeResponse::ReplyChunkAccepted { .. }
14854        | NodeResponse::CallRejected { .. }
14855        | NodeResponse::DeliveryStageBegun { .. }
14856        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14857        | NodeResponse::DeliveryCommitted { .. }
14858        | NodeResponse::DeliveryStageAborted { .. }
14859        | NodeResponse::WorkspaceInspected { .. }
14860        | NodeResponse::HostDirectoriesBrowsed { .. }
14861        | NodeResponse::WorkspaceFileRead { .. }
14862        | NodeResponse::WorkspaceFileWritten { .. }
14863        | NodeResponse::WorkspaceFileCreated { .. }
14864        | NodeResponse::WorkspaceDirectoryCreated { .. }
14865        | NodeResponse::GitHistoryRead { .. }
14866        | NodeResponse::GitDiffRead { .. }
14867        | NodeResponse::Controller { .. }
14868        | NodeResponse::SpawnAccepted { .. }
14869        | NodeResponse::SpawnSpecAccepted { .. }
14870        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14871        | NodeResponse::ManagedWorktreeCleanup { .. }
14872        | NodeResponse::NativeSessionsCataloged { .. }
14873        | NodeResponse::NativeSessionsPaged { .. }
14874        | NodeResponse::NativeSessionPreviewed { .. }
14875        | NodeResponse::SessionRecordPreviewed { .. }
14876        | NodeResponse::HistoryDiscovered { .. }
14877        | NodeResponse::HistoryLoaded { .. }
14878        | NodeResponse::ContextPackForSessionRecordExported { .. }
14879        | NodeResponse::ContextPackExported { .. }
14880        | NodeResponse::ContextPackForgotten { .. }
14881        | NodeResponse::DurableContextPackResolved { .. }
14882        | NodeResponse::ContextPackBytesRead { .. }
14883        | NodeResponse::SessionRecordForgotten { .. }
14884        | NodeResponse::WorkspaceRegistered { .. }
14885        | NodeResponse::StandaloneWorkspaceCreated { .. }
14886        | NodeResponse::WorkspaceUnregistered { .. }
14887        | NodeResponse::WorktreeCreated { .. }
14888        | NodeResponse::WorktreeRemoved { .. }
14889        | NodeResponse::Accepted
14890        | NodeResponse::ShuttingDown => {}
14891    }
14892}
14893
14894fn project_snapshot_history_for_wire(
14895    snapshot: &mut NodeSnapshot,
14896    include_history_metadata: bool,
14897) {
14898    for workspace in &mut snapshot.workspaces {
14899        for session in &mut workspace.sessions {
14900            if include_history_metadata {
14901                session.history.loaded = None;
14902            } else {
14903                session.history = Default::default();
14904            }
14905        }
14906    }
14907}
14908
14909fn project_event_history_for_wire(
14910    mut envelope: NodeEventEnvelope,
14911    include_history_metadata: bool,
14912) -> NodeEventEnvelope {
14913    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14914        for session in &mut workspace.sessions {
14915            if include_history_metadata {
14916                session.history.loaded = None;
14917            } else {
14918                session.history = Default::default();
14919            }
14920        }
14921    }
14922    envelope
14923}
14924
14925fn project_response_history_for_wire(
14926    reply: &mut ResponseEnvelope,
14927    include_history_metadata: bool,
14928) {
14929    let Ok(response) = reply.result.as_mut() else {
14930        return;
14931    };
14932    match response {
14933        NodeResponse::Snapshot { snapshot, .. } => {
14934            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14935        }
14936        NodeResponse::Resync {
14937            snapshot, events, ..
14938        } => {
14939            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14940            for event in events {
14941                *event = project_event_history_for_wire(
14942                    event.clone(),
14943                    include_history_metadata,
14944                );
14945            }
14946        }
14947        NodeResponse::WorkspaceRegistered { workspace }
14948        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14949        | NodeResponse::WorktreeCreated { workspace, .. } => {
14950            for session in &mut workspace.sessions {
14951                if include_history_metadata {
14952                    session.history.loaded = None;
14953                } else {
14954                    session.history = Default::default();
14955                }
14956            }
14957        }
14958        _ => {}
14959    }
14960}
14961
14962fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14963    for record in &mut snapshot.session_records {
14964        record.context_id = None;
14965        record.context = None;
14966        record.exported_context = None;
14967    }
14968}
14969
14970fn project_event_without_context_pack(
14971    mut envelope: NodeEventEnvelope,
14972) -> NodeEventEnvelope {
14973    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14974        record.context_id = None;
14975        record.context = None;
14976        record.exported_context = None;
14977    }
14978    envelope
14979}
14980
14981fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14982    let contains_context = match reply.result.as_ref() {
14983        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14984        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14985            receipt.spawn.context.is_some()
14986        }
14987        Ok(NodeResponse::HistoryDiscovered { .. })
14988        | Ok(NodeResponse::HistoryLoaded { .. })
14989        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14990        | Ok(NodeResponse::ContextPackExported { .. })
14991        | Ok(NodeResponse::ContextPackForgotten { .. })
14992        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14993        _ => false,
14994    };
14995    if contains_context {
14996        reply.result = Err(failure(
14997            NodeFailureCode::UnsupportedCapability,
14998            "history context packs were not negotiated",
14999        ));
15000        return;
15001    }
15002    let Ok(response) = reply.result.as_mut() else {
15003        return;
15004    };
15005    match response {
15006        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15007        NodeResponse::Resync {
15008            snapshot, events, ..
15009        } => {
15010            clear_snapshot_context_packs(snapshot);
15011            for event in events {
15012                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15013                    record.context_id = None;
15014                    record.context = None;
15015                    record.exported_context = None;
15016                }
15017            }
15018        }
15019        NodeResponse::SessionRecordUpdated { record }
15020        | NodeResponse::ProviderSessionIndexed { record }
15021        | NodeResponse::NativeSessionIndexed { record, .. }
15022        | NodeResponse::SessionRecordResumed { record, .. } => {
15023            record.context_id = None;
15024            record.context = None;
15025            record.exported_context = None;
15026        }
15027        _ => {}
15028    }
15029}
15030
15031fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15032    let contains_open_record = match reply.result.as_ref() {
15033        Ok(NodeResponse::SessionRecordUpdated { record })
15034        | Ok(NodeResponse::ProviderSessionIndexed { record })
15035        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15036            !provider_id_is_legacy(&record.provider)
15037        }
15038        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15039            !provider_id_is_legacy(&selection.route.provider)
15040                || !provider_id_is_legacy(&record.provider)
15041        }
15042        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15043            !provider_id_is_legacy(&receipt.provider)
15044        }
15045        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15046            !provider_id_is_legacy(&receipt.spawn.provider)
15047        }
15048        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15049        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15050            !provider_id_is_legacy(&route.provider)
15051        }
15052        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15053            !provider_id_is_legacy(&selection.route.provider)
15054        }
15055        _ => false,
15056    };
15057    if contains_open_record {
15058        reply.result = Err(failure(
15059            NodeFailureCode::UnsupportedCapability,
15060            "open provider IDs were not negotiated",
15061        ));
15062        return;
15063    }
15064    let Ok(response) = reply.result.as_mut() else {
15065        return;
15066    };
15067    match response {
15068        NodeResponse::Snapshot { snapshot, .. } => {
15069            project_snapshot_legacy_provider_ids(snapshot);
15070        }
15071        NodeResponse::Resync { snapshot, events, .. } => {
15072            project_snapshot_legacy_provider_ids(snapshot);
15073            *events = std::mem::take(events)
15074                .into_iter()
15075                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15076                .collect();
15077        }
15078        NodeResponse::WorkspaceRegistered { workspace }
15079        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15080        | NodeResponse::WorktreeCreated { workspace, .. } => {
15081            workspace
15082                .sessions
15083                .retain(|session| provider_id_is_legacy(&session.agent_id));
15084        }
15085        NodeResponse::Armed { .. }
15086        | NodeResponse::Spawned { .. }
15087        | NodeResponse::Activated { .. }
15088        | NodeResponse::Aborted { .. }
15089        | NodeResponse::ReplyChunkAccepted { .. }
15090        | NodeResponse::CallRejected { .. }
15091        | NodeResponse::DeliveryStageBegun { .. }
15092        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15093        | NodeResponse::DeliveryCommitted { .. }
15094        | NodeResponse::DeliveryStageAborted { .. }
15095        | NodeResponse::WorkspaceInspected { .. }
15096        | NodeResponse::HostDirectoriesBrowsed { .. }
15097        | NodeResponse::WorkspaceFileRead { .. }
15098        | NodeResponse::WorkspaceFileWritten { .. }
15099        | NodeResponse::WorkspaceFileCreated { .. }
15100        | NodeResponse::WorkspaceDirectoryCreated { .. }
15101        | NodeResponse::GitHistoryRead { .. }
15102        | NodeResponse::GitDiffRead { .. }
15103        | NodeResponse::Controller { .. }
15104        | NodeResponse::SpawnAccepted { .. }
15105        | NodeResponse::SpawnSpecAccepted { .. }
15106        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15107        | NodeResponse::ManagedWorktreeCleanup { .. }
15108        | NodeResponse::NativeSessionsCataloged { .. }
15109        | NodeResponse::NativeSessionsPaged { .. }
15110        | NodeResponse::NativeSessionPreviewed { .. }
15111        | NodeResponse::SessionRecordPreviewed { .. }
15112        | NodeResponse::HistoryDiscovered { .. }
15113        | NodeResponse::HistoryLoaded { .. }
15114        | NodeResponse::ContextPackForSessionRecordExported { .. }
15115        | NodeResponse::ContextPackExported { .. }
15116        | NodeResponse::ContextPackForgotten { .. }
15117        | NodeResponse::DurableContextPackResolved { .. }
15118        | NodeResponse::ContextPackBytesRead { .. }
15119        | NodeResponse::SessionRecordUpdated { .. }
15120        | NodeResponse::ProviderSessionIndexed { .. }
15121        | NodeResponse::NativeSessionIndexed { .. }
15122        | NodeResponse::SessionRecordResumed { .. }
15123        | NodeResponse::SessionRecordForgotten { .. }
15124        | NodeResponse::WorkspaceUnregistered { .. }
15125        | NodeResponse::WorktreeRemoved { .. }
15126        | NodeResponse::Accepted
15127        | NodeResponse::ShuttingDown => {}
15128    }
15129}
15130
15131fn project_event_legacy_provider_ids(
15132    shared: &NodeShared,
15133    mut envelope: NodeEventEnvelope,
15134) -> Option<NodeEventEnvelope> {
15135    let include = match &mut envelope.event {
15136        NodeEvent::HarnessMcpReadCall { .. } => true,
15137        NodeEvent::Control { address, .. }
15138        | NodeEvent::AgentStream { address, .. }
15139        | NodeEvent::TerminalFrame { address, .. } => shared
15140            .validate_address(address)
15141            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15142        NodeEvent::WorkspaceAdded { workspace } => {
15143            workspace
15144                .sessions
15145                .retain(|session| provider_id_is_legacy(&session.agent_id));
15146            true
15147        }
15148        NodeEvent::SessionRecordUpserted { record } => {
15149            provider_id_is_legacy(&record.provider)
15150        }
15151        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15152        NodeEvent::SessionRecordRemoved { .. } => shared
15153            .history
15154            .lock()
15155            .unwrap_or_else(|poisoned| poisoned.into_inner())
15156            .removed_record_providers
15157            .get(&envelope.sequence)
15158            .is_some_and(provider_id_is_legacy),
15159        NodeEvent::ControllerChanged { .. }
15160        | NodeEvent::ManagedWorktreeUpserted { .. }
15161        | NodeEvent::ManagedWorktreeRemoved { .. }
15162        | NodeEvent::WorkspaceRemoved { .. }
15163        | NodeEvent::ResyncRequired { .. } => true,
15164    };
15165    include.then_some(envelope)
15166}
15167
15168fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15169    let Ok(response) = reply.result.as_mut() else {
15170        return;
15171    };
15172    match response {
15173        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15174            snapshot.provider_runtime_statuses.clear();
15175        }
15176        NodeResponse::Armed { .. }
15177        | NodeResponse::Spawned { .. }
15178        | NodeResponse::Activated { .. }
15179        | NodeResponse::Aborted { .. }
15180        | NodeResponse::ReplyChunkAccepted { .. }
15181        | NodeResponse::CallRejected { .. }
15182        | NodeResponse::DeliveryStageBegun { .. }
15183        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15184        | NodeResponse::DeliveryCommitted { .. }
15185        | NodeResponse::DeliveryStageAborted { .. }
15186        | NodeResponse::WorkspaceInspected { .. }
15187        | NodeResponse::HostDirectoriesBrowsed { .. }
15188        | NodeResponse::WorkspaceFileRead { .. }
15189        | NodeResponse::WorkspaceFileWritten { .. }
15190        | NodeResponse::WorkspaceFileCreated { .. }
15191        | NodeResponse::WorkspaceDirectoryCreated { .. }
15192        | NodeResponse::GitHistoryRead { .. }
15193        | NodeResponse::GitDiffRead { .. }
15194        | NodeResponse::Controller { .. }
15195        | NodeResponse::SpawnAccepted { .. }
15196        | NodeResponse::SpawnSpecAccepted { .. }
15197        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15198        | NodeResponse::ManagedWorktreeCleanup { .. }
15199        | NodeResponse::NativeSessionsCataloged { .. }
15200        | NodeResponse::NativeSessionsPaged { .. }
15201        | NodeResponse::NativeSessionPreviewed { .. }
15202        | NodeResponse::SessionRecordPreviewed { .. }
15203        | NodeResponse::HistoryDiscovered { .. }
15204        | NodeResponse::HistoryLoaded { .. }
15205        | NodeResponse::ContextPackForSessionRecordExported { .. }
15206        | NodeResponse::ContextPackExported { .. }
15207        | NodeResponse::ContextPackForgotten { .. }
15208        | NodeResponse::DurableContextPackResolved { .. }
15209        | NodeResponse::ContextPackBytesRead { .. }
15210        | NodeResponse::SessionRecordUpdated { .. }
15211        | NodeResponse::ProviderSessionIndexed { .. }
15212        | NodeResponse::NativeSessionIndexed { .. }
15213        | NodeResponse::SessionRecordResumed { .. }
15214        | NodeResponse::SessionRecordForgotten { .. }
15215        | NodeResponse::WorkspaceRegistered { .. }
15216        | NodeResponse::StandaloneWorkspaceCreated { .. }
15217        | NodeResponse::WorkspaceUnregistered { .. }
15218        | NodeResponse::WorktreeCreated { .. }
15219        | NodeResponse::WorktreeRemoved { .. }
15220        | NodeResponse::Accepted
15221        | NodeResponse::ShuttingDown => {}
15222    }
15223}
15224
15225struct ControllerReleaseGuard {
15226    shared: Arc<NodeShared>,
15227    connection_id: u64,
15228}
15229
15230impl Drop for ControllerReleaseGuard {
15231    fn drop(&mut self) {
15232        self.shared.release_controller(self.connection_id);
15233    }
15234}
15235
15236struct AbortTaskOnDrop(AbortHandle);
15237
15238impl Drop for AbortTaskOnDrop {
15239    fn drop(&mut self) {
15240        self.0.abort();
15241    }
15242}
15243
15244/// Reads an unsigned env-config override, following the pattern of every
15245/// other bounded knob in this module: missing/unparsable/zero falls back to
15246/// `default`, and the resolved value is always clamped to `max` so a bad
15247/// override cannot reopen a bound this budget exists to enforce.
15248fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15249    std::env::var(name)
15250        .ok()
15251        .and_then(|value| value.parse::<u64>().ok())
15252        .filter(|value| *value > 0)
15253        .unwrap_or(default)
15254        .min(max)
15255}
15256
15257fn workspace_inspection_time_budget_ms() -> u64 {
15258    env_config_bounded(
15259        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15260        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15261        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15262    )
15263}
15264
15265fn workspace_inspection_entry_cap() -> usize {
15266    env_config_bounded(
15267        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15268        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15269        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15270    ) as usize
15271}
15272
15273/// Tracks the shared walk-side budget across the whole recursive walk: a
15274/// wall-clock deadline (shared with the git phase that runs after the walk)
15275/// and a cap on directory entries visited, whether or not they were pushed
15276/// to the response. Cheap to check per entry — one `Instant::now()` and one
15277/// integer compare.
15278struct WorkspaceWalkBudget {
15279    deadline: Instant,
15280    entry_cap: usize,
15281    entries_visited: u64,
15282    time_budget_exceeded: bool,
15283    entry_cap_exceeded: bool,
15284}
15285
15286impl WorkspaceWalkBudget {
15287    fn new(deadline: Instant, entry_cap: usize) -> Self {
15288        Self {
15289            deadline,
15290            entry_cap,
15291            entries_visited: 0,
15292            time_budget_exceeded: false,
15293            entry_cap_exceeded: false,
15294        }
15295    }
15296
15297    fn is_exhausted(&self) -> bool {
15298        self.time_budget_exceeded || self.entry_cap_exceeded
15299    }
15300
15301    /// Records one visited directory entry (pushed to the response or not)
15302    /// and reports whether the walk must stop now.
15303    fn record_visit(&mut self) -> bool {
15304        self.entries_visited += 1;
15305        if self.entries_visited as usize >= self.entry_cap {
15306            self.entry_cap_exceeded = true;
15307        }
15308        if Instant::now() >= self.deadline {
15309            self.time_budget_exceeded = true;
15310        }
15311        self.is_exhausted()
15312    }
15313}
15314
15315fn collect_workspace_entries(
15316    root: &Path,
15317    deadline: Instant,
15318    entry_cap: usize,
15319) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15320    let mut entries = Vec::new();
15321    let mut truncated = false;
15322    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15323    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15324    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15325    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15326    // require strict global byte order over relative paths.
15327    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15328    (entries, truncated, budget)
15329}
15330
15331/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15332/// `inspect_workspace` response — `None` once the walk and git phase both
15333/// completed inside their shared budget.
15334fn workspace_inspection_truncation(
15335    walk_budget: &WorkspaceWalkBudget,
15336    git_time_budget_exceeded: bool,
15337    elapsed: Duration,
15338) -> Option<WorkspaceInspectionTruncationV1> {
15339    if !walk_budget.time_budget_exceeded
15340        && !walk_budget.entry_cap_exceeded
15341        && !git_time_budget_exceeded
15342    {
15343        return None;
15344    }
15345    Some(WorkspaceInspectionTruncationV1 {
15346        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15347        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15348        git_time_budget_exceeded,
15349        entries_visited: walk_budget.entries_visited,
15350        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15351    })
15352}
15353
15354fn walk_workspace_directory(
15355    directory: &Path,
15356    relative_directory: Option<&RepositoryPath>,
15357    depth: usize,
15358    entries: &mut Vec<WorkspaceEntry>,
15359    truncated: &mut bool,
15360    budget: &mut WorkspaceWalkBudget,
15361) {
15362    if budget.is_exhausted() {
15363        *truncated = true;
15364        return;
15365    }
15366    let read_dir = match std::fs::read_dir(directory) {
15367        Ok(read_dir) => read_dir,
15368        Err(_) => {
15369            *truncated = true;
15370            return;
15371        }
15372    };
15373    let mut children = Vec::new();
15374    for child in read_dir {
15375        match child {
15376            Ok(child) => children.push(child),
15377            Err(_) => *truncated = true,
15378        }
15379    }
15380    children.sort_by_key(|child| child.file_name());
15381    for child in children {
15382        if budget.record_visit() {
15383            *truncated = true;
15384            return;
15385        }
15386        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15387            *truncated = true;
15388            return;
15389        }
15390        let file_type = match child.file_type() {
15391            Ok(file_type) => file_type,
15392            Err(_) => {
15393                *truncated = true;
15394                continue;
15395            }
15396        };
15397        if file_type.is_symlink() {
15398            continue;
15399        }
15400        let name = match child.file_name().into_string() {
15401            Ok(name) => name,
15402            Err(_) => {
15403                *truncated = true;
15404                continue;
15405            }
15406        };
15407        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15408            continue;
15409        }
15410        let relative_path = match relative_directory {
15411            None => windows_repository_path(name),
15412            Some(parent) => parent
15413                .as_utf8()
15414                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15415        };
15416        let Some(relative_path) = relative_path else {
15417            *truncated = true;
15418            continue;
15419        };
15420        let kind = if file_type.is_dir() {
15421            WorkspaceEntryKind::Directory
15422        } else if file_type.is_file() {
15423            WorkspaceEntryKind::File
15424        } else {
15425            continue;
15426        };
15427        entries.push(WorkspaceEntry {
15428            relative_path: relative_path.clone(),
15429            kind,
15430        });
15431        if file_type.is_dir() {
15432            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15433                walk_workspace_directory(
15434                    &child.path(),
15435                    Some(&relative_path),
15436                    depth + 1,
15437                    entries,
15438                    truncated,
15439                    budget,
15440                );
15441            } else {
15442                *truncated = true;
15443            }
15444        }
15445    }
15446}
15447
15448fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15449    if value.contains('\\') {
15450        return None;
15451    }
15452    RepositoryPath::utf8(value).ok()
15453}
15454
15455fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15456    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15457}
15458
15459/// Excludes a directory from the workspace tree walk. The three literal
15460/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15461/// beneath them catches every build-output directory regardless of name
15462/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15463/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15464/// directory without maintaining a name heuristic list). The name check
15465/// runs first and returns early so a directory rejected by name never pays
15466/// for the extra `CACHEDIR.TAG` stat.
15467fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15468    if name.eq_ignore_ascii_case(".git")
15469        || name.eq_ignore_ascii_case("target")
15470        || name.eq_ignore_ascii_case("node_modules")
15471    {
15472        return true;
15473    }
15474    path.join("CACHEDIR.TAG").is_file()
15475}
15476
15477fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15478    matches!(
15479        status,
15480        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15481    )
15482}
15483
15484#[derive(Clone, Debug, Eq, PartialEq)]
15485enum ContextPackRepositoryHead {
15486    NotRepository,
15487    Unborn,
15488    Commit(String),
15489}
15490
15491#[derive(Clone, Debug, Eq, PartialEq)]
15492struct ContextPackRepositoryObservation {
15493    head: ContextPackRepositoryHead,
15494    git: GitSnapshot,
15495    files: Vec<ContextPackRepositoryFileSource>,
15496}
15497
15498fn stable_context_pack_repository(
15499    first: ContextPackRepositoryObservation,
15500    second: ContextPackRepositoryObservation,
15501) -> Result<ContextPackRepository, NodeFailure> {
15502    if first != second {
15503        return Err(failure(
15504            NodeFailureCode::ContextPackMaterializationFailed,
15505            "context repository changed during bounded capture",
15506        ));
15507    }
15508    Ok(ContextPackRepository::from_sources(
15509        &second.git,
15510        second.files,
15511    ))
15512}
15513
15514/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15515/// (and only once) the shared walk+git `deadline` has already passed. Every
15516/// remaining phase in `inspect_git_workspace` checks this immediately
15517/// before issuing its own git call, so a budget spent by the walk (or by an
15518/// earlier git call) skips the rest of the chain instead of paying for it
15519/// anyway.
15520fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15521    if Instant::now() < deadline {
15522        return false;
15523    }
15524    append_git_diagnostic(
15525        snapshot,
15526        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15527    );
15528    true
15529}
15530
15531/// Deadline-aware sibling of `run_git_bounded`, used only by
15532/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15533/// whatever remains of the shared `deadline` (never more than
15534/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15535/// of the budget cannot still hand a full per-call timeout to every git
15536/// probe behind it. `inspect_git_workspace` already guards each call site
15537/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15538/// passed yet here.
15539async fn run_git_bounded_with_deadline(
15540    root: &str,
15541    arguments: &[&str],
15542    output_limit: usize,
15543    deadline: Instant,
15544) -> io::Result<GitCommandOutput> {
15545    let remaining = deadline.checked_duration_since(Instant::now());
15546    let timeout_ms = remaining
15547        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15548        .unwrap_or(0)
15549        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15550    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15551}
15552
15553/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15554/// `deadline` is the same absolute instant the preceding directory walk was
15555/// bounded by, so time already spent walking counts against what remains
15556/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15557/// shared budget (not an individual per-call timeout) is what cut the git
15558/// phase short.
15559async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15560    let mut snapshot = GitSnapshot {
15561        is_repository: false,
15562        branch: None,
15563        status: Vec::new(),
15564        recent_commits: Vec::new(),
15565        worktrees: Vec::new(),
15566        managed_worktree: None,
15567        truncated: false,
15568        diagnostic: None,
15569    };
15570    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15571        return (snapshot, true);
15572    }
15573    let repository = match run_git_bounded_with_deadline(
15574        root,
15575        &["rev-parse", "--is-inside-work-tree"],
15576        4 * 1_024,
15577        deadline,
15578    )
15579    .await
15580    {
15581        Ok(output) => output,
15582        Err(error) => {
15583            append_git_diagnostic(
15584                &mut snapshot,
15585                &format!("git inspection unavailable: {error}"),
15586            );
15587            return (snapshot, false);
15588        }
15589    };
15590    snapshot.truncated |= repository.truncated;
15591    if repository.timed_out {
15592        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15593        return (snapshot, false);
15594    }
15595    if !repository.success
15596        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15597    {
15598        let stderr = String::from_utf8_lossy(&repository.stderr);
15599        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15600            append_git_diagnostic(&mut snapshot, stderr.trim());
15601        }
15602        return (snapshot, false);
15603    }
15604    snapshot.is_repository = true;
15605
15606    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15607        return (snapshot, true);
15608    }
15609    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15610        Ok(output) => {
15611            snapshot.truncated |= output.truncated;
15612            if output.timed_out {
15613                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15614            } else if output.success {
15615                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15616                if !branch.is_empty() {
15617                    snapshot.branch = Some(truncate_git_field(
15618                        &branch,
15619                        MAX_REPOSITORY_PATH_BYTES,
15620                        &mut snapshot.truncated,
15621                    ));
15622                }
15623            }
15624        }
15625        Err(error) => append_git_diagnostic(
15626            &mut snapshot,
15627            &format!("git branch query failed: {error}"),
15628        ),
15629    }
15630    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15631        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15632            snapshot.truncated |= output.truncated;
15633            if output.success && !output.timed_out {
15634                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15635                if !head.is_empty() {
15636                    snapshot.branch = Some(format!("detached:{head}"));
15637                }
15638            }
15639        }
15640    }
15641
15642    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15643        return (snapshot, true);
15644    }
15645    match run_git_bounded_with_deadline(
15646        root,
15647        &[
15648            "status",
15649            "--porcelain=v1",
15650            "-z",
15651            "--renames",
15652            "--untracked-files=normal",
15653            "--",
15654            ".",
15655        ],
15656        GIT_OUTPUT_MAX_BYTES,
15657        deadline,
15658    )
15659    .await
15660    {
15661        Ok(output) => {
15662            snapshot.truncated |= output.truncated;
15663            if output.timed_out {
15664                append_git_diagnostic(&mut snapshot, "git status timed out");
15665            } else if output.success {
15666                parse_git_status(&output.stdout, &mut snapshot);
15667            } else {
15668                append_git_diagnostic(
15669                    &mut snapshot,
15670                    String::from_utf8_lossy(&output.stderr).trim(),
15671                );
15672            }
15673        }
15674        Err(error) => append_git_diagnostic(
15675            &mut snapshot,
15676            &format!("git status failed: {error}"),
15677        ),
15678    }
15679
15680    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15681        return (snapshot, true);
15682    }
15683    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15684    match run_git_bounded_with_deadline(
15685        root,
15686        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15687        16 * 1_024,
15688        deadline,
15689    )
15690    .await
15691    {
15692        Ok(output) => {
15693            snapshot.truncated |= output.truncated;
15694            if output.timed_out {
15695                append_git_diagnostic(&mut snapshot, "git log timed out");
15696            } else if output.success {
15697                parse_git_commits(&output.stdout, &mut snapshot);
15698            } else {
15699                let stderr = String::from_utf8_lossy(&output.stderr);
15700                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15701                    append_git_diagnostic(&mut snapshot, stderr.trim());
15702                }
15703            }
15704        }
15705        Err(error) => append_git_diagnostic(
15706            &mut snapshot,
15707            &format!("git log failed: {error}"),
15708        ),
15709    }
15710
15711    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15712        return (snapshot, true);
15713    }
15714    match list_git_worktrees_with_deadline(root, deadline).await {
15715        Ok(worktrees) => {
15716            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15717        }
15718        Err(error) => append_git_diagnostic(
15719            &mut snapshot,
15720            &format!("git worktree list failed: {}", error.message),
15721        ),
15722    }
15723    (snapshot, false)
15724}
15725
15726fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15727    let mut remaining = output;
15728    while !remaining.is_empty() {
15729        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15730            snapshot.truncated = true;
15731            break;
15732        }
15733        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15734            snapshot.truncated = true;
15735            break;
15736        };
15737        let record = &remaining[..record_end];
15738        remaining = &remaining[record_end + 1..];
15739        let has_previous_path = record
15740            .get(..2)
15741            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15742        let previous_path_bytes = if has_previous_path {
15743            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15744                snapshot.truncated = true;
15745                break;
15746            };
15747            let previous = &remaining[..previous_end];
15748            remaining = &remaining[previous_end + 1..];
15749            Some(previous)
15750        } else {
15751            None
15752        };
15753        if record.len() < 4
15754            || record[2] != b' '
15755            || !is_git_status_code(record[0])
15756            || !is_git_status_code(record[1])
15757        {
15758            snapshot.truncated = true;
15759            continue;
15760        }
15761        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15762            snapshot.truncated = true;
15763            continue;
15764        };
15765        let previous_path = match previous_path_bytes {
15766            Some(previous) => {
15767                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15768                    snapshot.truncated = true;
15769                    continue;
15770                };
15771                Some(previous)
15772            }
15773            None => None,
15774        };
15775        snapshot.status.push(GitStatusEntry {
15776            index_status: (record[0] as char).to_string(),
15777            worktree_status: (record[1] as char).to_string(),
15778            path,
15779            previous_path,
15780        });
15781    }
15782}
15783
15784fn is_git_status_code(byte: u8) -> bool {
15785    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15786}
15787
15788fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15789    for line in String::from_utf8_lossy(output).lines() {
15790        let Some((id, summary)) = line.split_once('\u{1f}') else {
15791            continue;
15792        };
15793        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15794            snapshot.truncated = true;
15795            break;
15796        }
15797        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15798        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15799        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15800    }
15801}
15802
15803fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15804    if value.len() <= max_bytes {
15805        return value.to_owned();
15806    }
15807    *truncated = true;
15808    let mut boundary = max_bytes;
15809    while boundary > 0 && !value.is_char_boundary(boundary) {
15810        boundary -= 1;
15811    }
15812    value[..boundary].to_owned()
15813}
15814
15815fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15816    if message.is_empty() {
15817        return;
15818    }
15819    let normalized = message
15820        .chars()
15821        .map(|character| if character.is_control() { ' ' } else { character })
15822        .collect::<String>();
15823    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15824    if !diagnostic.is_empty() {
15825        diagnostic.push_str("; ");
15826    }
15827    diagnostic.push_str(normalized.trim());
15828    snapshot.diagnostic = Some(truncate_git_field(
15829        &diagnostic,
15830        GIT_DIAGNOSTIC_MAX_BYTES,
15831        &mut snapshot.truncated,
15832    ));
15833}
15834
15835async fn run_git_bounded(
15836    root: &str,
15837    arguments: &[&str],
15838    output_limit: usize,
15839) -> io::Result<GitCommandOutput> {
15840    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15841}
15842
15843async fn read_git_history_bounded(
15844    root: &str,
15845    path: Option<&RepositoryPath>,
15846    before: Option<&GitObjectId>,
15847    limit: u16,
15848) -> Result<GitHistoryPage, NodeFailure> {
15849    let path = path
15850        .map(|path| {
15851            path.as_utf8().ok_or_else(|| {
15852                failure(
15853                    NodeFailureCode::InvalidRepositoryPath,
15854                    "git history path must use UTF-8",
15855                )
15856            })
15857        })
15858        .transpose()?
15859        .unwrap_or(".");
15860    let count = (usize::from(limit) + 1).to_string();
15861    let start = before
15862        .map(|revision| format!("{}^", revision.as_str()))
15863        .unwrap_or_else(|| "HEAD".to_owned());
15864    let arguments = [
15865        "--no-pager",
15866        "log",
15867        "--no-decorate",
15868        "--date=iso-strict",
15869        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15870        "-n",
15871        count.as_str(),
15872        start.as_str(),
15873        "--",
15874        path,
15875    ];
15876    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15877        .await
15878        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15879    if output.timed_out {
15880        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15881    }
15882    if !output.success {
15883        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15884    }
15885    let mut commits = parse_git_history(&output.stdout);
15886    let has_more = commits.len() > usize::from(limit) || output.truncated;
15887    commits.truncate(usize::from(limit));
15888    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15889    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15890}
15891
15892fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15893    String::from_utf8_lossy(output)
15894        .split('\u{1e}')
15895        .filter_map(|record| {
15896            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15897            if fields.len() != 9 {
15898                return None;
15899            }
15900            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15901            let parents = if fields[1].is_empty() {
15902                Vec::new()
15903            } else {
15904                fields[1]
15905                    .split(' ')
15906                    .map(|value| GitObjectId::new(value.to_owned()))
15907                    .collect::<Result<Vec<_>, _>>()
15908                    .ok()?
15909            };
15910            Some(GitCommitDetails {
15911                id,
15912                parents,
15913                subject: bounded_git_text(fields[2], 512),
15914                author_name: bounded_git_text(fields[3], 256),
15915                author_email: bounded_git_text(fields[4], 320),
15916                authored_at: bounded_git_text(fields[5], 64),
15917                committer_name: bounded_git_text(fields[6], 256),
15918                committer_email: bounded_git_text(fields[7], 320),
15919                committed_at: bounded_git_text(fields[8], 64),
15920                signature_status: GitSignatureStatus::NoSignature,
15921                signer: None,
15922            })
15923        })
15924        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15925        .collect()
15926}
15927
15928fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15929    let normalized = value
15930        .chars()
15931        .map(|character| if character.is_control() { ' ' } else { character })
15932        .collect::<String>();
15933    if normalized.len() <= max_bytes {
15934        return normalized;
15935    }
15936    let mut boundary = max_bytes;
15937    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15938        boundary -= 1;
15939    }
15940    normalized[..boundary].to_owned()
15941}
15942
15943async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15944    let path = request.path.as_ref().map(|path| {
15945        path.as_utf8()
15946            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15947    }).transpose()?;
15948    let mut arguments = match &request.mode {
15949        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15950        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15951        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15952    };
15953    if let GitDiffMode::Commit { revision } = &request.mode {
15954        arguments.push(revision.as_str());
15955    }
15956    arguments.push("--");
15957    if let Some(path) = path {
15958        arguments.push(path);
15959    }
15960    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15961        .await
15962        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15963    if output.timed_out {
15964        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15965    }
15966    if !output.success {
15967        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15968    }
15969    Ok(GitDiff {
15970        mode: request.mode,
15971        path: request.path,
15972        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15973        truncated: output.truncated,
15974    })
15975}
15976
15977async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15978    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15979    ResponseEnvelope { request_id: envelope.request_id, result }
15980}
15981
15982async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15983    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15984        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15985    }
15986    let read_only = matches!(
15987        &request,
15988        NodeRequest::Snapshot
15989            | NodeRequest::Resync { .. }
15990            | NodeRequest::BrowseHostDirectories { .. }
15991            | NodeRequest::InspectWorkspace { .. }
15992            | NodeRequest::ReadWorkspaceFile { .. }
15993            | NodeRequest::ReadGitHistory { .. }
15994            | NodeRequest::ReadGitDiff { .. }
15995            | NodeRequest::CatalogNativeSessions { .. }
15996            | NodeRequest::PageNativeSessions { .. }
15997            | NodeRequest::PreviewNativeSession { .. }
15998            | NodeRequest::PreviewSessionRecord { .. }
15999    );
16000    let _mutation_guard = if read_only {
16001        None
16002    } else {
16003        let guard = shared.mutation_gate.lock().await;
16004        if shared.shutdown.load(Ordering::Acquire) {
16005            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16006        }
16007        Some(guard)
16008    };
16009    match request {
16010        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16011            event_sequence: shared.current_sequence(),
16012            controller: shared.controller_state(),
16013            snapshot: shared.snapshot(),
16014        }),
16015        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16016        NodeRequest::ArmHarnessMcpReservation {
16017            reservation_id,
16018            activation_digest,
16019            spawn_spec,
16020            launch,
16021            expires_at_unix_ms,
16022        } => {
16023            shared.require_controller(connection_id, role)?;
16024            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16025                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16026            })?;
16027            let expires_at_unix_ms = registry
16028                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16029                .await
16030                .map_err(harness_mcp_failure)?;
16031            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16032        }
16033        NodeRequest::SpawnSpecWithHarnessMcp {
16034            reservation_id,
16035            activation_digest,
16036            spec,
16037            deadline_unix_ms,
16038        } => {
16039            shared.require_controller(connection_id, role)?;
16040            let receipt = shared
16041                .spawn_from_spec_with_harness_mcp(
16042                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16043                )
16044                .await?;
16045            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16046        }
16047        NodeRequest::ActivateHarnessMcpReservation {
16048            reservation_id,
16049            activation_digest,
16050            record_id,
16051            session,
16052        } => {
16053            shared.require_controller(connection_id, role)?;
16054            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16055                current.instance_id == session.session.instance_id
16056                    && current.generation == session.session.generation
16057            }).and_then(|current| current.process_id).ok_or_else(|| {
16058                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16059            })?;
16060            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16061                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16062            })?.activate(
16063                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16064            ).map_err(harness_mcp_failure)?;
16065            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16066        }
16067        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16068            shared.require_controller(connection_id, role)?;
16069            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16070                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16071            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16072            if let Some(instance_id) = instance_id {
16073                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16074            }
16075            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16076        }
16077        NodeRequest::PutHarnessMcpReplyChunk {
16078            reservation_id, activation_digest, record_id, session, call_id,
16079            offset, final_chunk, chunk_hex,
16080        } => {
16081            shared.require_controller(connection_id, role)?;
16082            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16083                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16084            })?.put_reply_chunk(
16085                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16086                offset, final_chunk, &chunk_hex,
16087            ).map_err(harness_mcp_failure)?;
16088            Ok(NodeResponse::ReplyChunkAccepted {
16089                reservation_id, activation_digest, record_id, session, call_id,
16090                next_offset, completed,
16091            })
16092        }
16093        NodeRequest::RejectHarnessMcpCall {
16094            reservation_id, activation_digest, record_id, session, call_id, reason,
16095        } => {
16096            shared.require_controller(connection_id, role)?;
16097            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16098                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16099            })?.reject_call(
16100                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16101            ).map_err(harness_mcp_failure)?;
16102            Ok(NodeResponse::CallRejected {
16103                reservation_id, activation_digest, record_id, session, call_id,
16104            })
16105        }
16106        NodeRequest::BeginDeliveryStage { manifest } => {
16107            shared.require_controller(connection_id, role)?;
16108            shared.begin_delivery_stage(manifest)
16109        }
16110        NodeRequest::PutDeliveryBlobChunk {
16111            stage_id,
16112            blob_digest,
16113            offset,
16114            chunk_hex,
16115        } => {
16116            shared.require_controller(connection_id, role)?;
16117            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16118        }
16119        NodeRequest::CommitDeliveryStage { stage_id } => {
16120            shared.require_controller(connection_id, role)?;
16121            let receipt = shared.commit_delivery_stage(stage_id)?;
16122            Ok(NodeResponse::DeliveryCommitted { receipt })
16123        }
16124        NodeRequest::AbortDeliveryStage { stage_id } => {
16125            shared.require_controller(connection_id, role)?;
16126            shared.abort_delivery_stage(&stage_id)?;
16127            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16128        }
16129        NodeRequest::BrowseHostDirectories { directory, after } => {
16130            let listing = shared.browse_host_directories(directory, after).await?;
16131            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16132        }
16133        NodeRequest::InspectWorkspace { workspace_id } => {
16134            let inspection = shared.inspect_workspace(workspace_id).await?;
16135            Ok(NodeResponse::WorkspaceInspected { inspection })
16136        }
16137        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16138            let file = shared.read_workspace_file(workspace_id, path).await?;
16139            Ok(NodeResponse::WorkspaceFileRead { file })
16140        }
16141        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16142            shared.require_controller(connection_id, role)?;
16143            let file = shared
16144                .write_workspace_file(workspace_id, path, expected_revision, text)
16145                .await?;
16146            Ok(NodeResponse::WorkspaceFileWritten { file })
16147        }
16148        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16149            shared.require_controller(connection_id, role)?;
16150            let file = shared.create_workspace_file(workspace_id, path).await?;
16151            Ok(NodeResponse::WorkspaceFileCreated { file })
16152        }
16153        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16154            shared.require_controller(connection_id, role)?;
16155            let entry = shared
16156                .create_workspace_directory(workspace_id.clone(), path)
16157                .await?;
16158            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16159        }
16160        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16161            if role != ClientRole::Operator {
16162                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16163            }
16164            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16165                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16166            }
16167            let page = shared
16168                .read_git_history(workspace_id.clone(), path, before, limit)
16169                .await?;
16170            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16171        }
16172        NodeRequest::ReadGitDiff { workspace_id, request } => {
16173            if role != ClientRole::Operator {
16174                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16175            }
16176            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16177            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16178        }
16179        NodeRequest::CatalogNativeSessions { route, limit } => {
16180            if role != ClientRole::Operator {
16181                return Err(failure(
16182                    NodeFailureCode::ObserverReadOnly,
16183                    "native session catalog requires an operator connection",
16184                ));
16185            }
16186            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16187                return Err(failure(
16188                    NodeFailureCode::InvalidRequest,
16189                    "native session catalog limit is invalid",
16190                ));
16191            }
16192            let (entries, summary) = shared
16193                .catalog_native_sessions(route.clone(), limit)
16194                .await?;
16195            Ok(NodeResponse::NativeSessionsCataloged {
16196                route,
16197                entries,
16198                summary: Some(summary),
16199            })
16200        }
16201        NodeRequest::PageNativeSessions {
16202            route,
16203            window,
16204            catalog_revision,
16205            recent_cutoff_unix_ms,
16206            after_selection_id,
16207            limit,
16208        } => {
16209            if role != ClientRole::Operator {
16210                return Err(failure(
16211                    NodeFailureCode::ObserverReadOnly,
16212                    "native session catalog paging requires an operator connection",
16213                ));
16214            }
16215            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16216                || after_selection_id
16217                    .as_deref()
16218                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16219            {
16220                return Err(failure(
16221                    NodeFailureCode::InvalidRequest,
16222                    "native session catalog page request is invalid",
16223                ));
16224            }
16225            let page = shared
16226                .page_native_sessions(
16227                    route.clone(),
16228                    window,
16229                    catalog_revision,
16230                    recent_cutoff_unix_ms,
16231                    after_selection_id,
16232                    limit,
16233                )
16234                .await?;
16235            Ok(NodeResponse::NativeSessionsPaged {
16236                route,
16237                page,
16238            })
16239        }
16240        NodeRequest::PreviewNativeSession {
16241            selection,
16242            message_limit,
16243        } => {
16244            if role != ClientRole::Operator {
16245                return Err(failure(
16246                    NodeFailureCode::ObserverReadOnly,
16247                    "native session preview requires an operator connection",
16248                ));
16249            }
16250            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16251                .contains(&message_limit)
16252                || selection.validate().is_err()
16253            {
16254                return Err(failure(
16255                    NodeFailureCode::InvalidRequest,
16256                    "native session preview request is invalid",
16257                ));
16258            }
16259            let preview = shared
16260                .preview_native_session(selection.clone(), message_limit)
16261                .await?;
16262            Ok(NodeResponse::NativeSessionPreviewed {
16263                selection,
16264                preview,
16265            })
16266        }
16267        NodeRequest::IndexNativeSession {
16268            selection,
16269            display_name,
16270        } => {
16271            shared.require_controller(connection_id, role)?;
16272            let record = shared
16273                .index_native_session(selection.clone(), display_name)
16274                .await?;
16275            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16276        }
16277        NodeRequest::PreviewSessionRecord {
16278            record_id,
16279            message_limit,
16280        } => {
16281            if role != ClientRole::Operator {
16282                return Err(failure(
16283                    NodeFailureCode::ObserverReadOnly,
16284                    "native session preview requires an operator connection",
16285                ));
16286            }
16287            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16288                .contains(&message_limit)
16289            {
16290                return Err(failure(
16291                    NodeFailureCode::InvalidRequest,
16292                    "native session preview request is invalid",
16293                ));
16294            }
16295            let record = shared.record(&record_id)?;
16296            let working_directory = shared.workspace_root(&record.workspace_id)?;
16297            if !platform::roots_equal(
16298                &working_directory,
16299                windows_path_text(&record.canonical_root),
16300            ) {
16301                return Err(failure(
16302                    NodeFailureCode::SessionRecordConflict,
16303                    "managed session workspace root changed; refusing to preview another directory",
16304                ));
16305            }
16306            let identity = record.provider_session.clone().ok_or_else(|| {
16307                failure(
16308                    NodeFailureCode::BackendOperationFailed,
16309                    "native session preview is unavailable for this session record",
16310                )
16311            })?;
16312            if identity.key != ProviderSessionKey::SessionId {
16313                return Err(failure(
16314                    NodeFailureCode::BackendOperationFailed,
16315                    "native session preview is unavailable for this session identity",
16316                ));
16317            }
16318            let preview = shared
16319                .preview_native_session_by_session_id(
16320                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16321                )
16322                .await?;
16323            if preview.session_id != identity.id {
16324                return Err(failure(
16325                    NodeFailureCode::SessionRecordConflict,
16326                    "native session preview identity changed while loading",
16327                ));
16328            }
16329            shared.revalidate_session_record_preview(&record, &identity)?;
16330            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16331            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16332                record_id: record_id.clone(),
16333                summary: SessionHistorySummaryV1::from(&preview),
16334            });
16335            Ok(NodeResponse::SessionRecordPreviewed {
16336                record_id,
16337                preview,
16338            })
16339        }
16340        NodeRequest::AcquireController { lease_ms } => {
16341            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16342            Ok(NodeResponse::Controller { controller: Some(controller) })
16343        }
16344        NodeRequest::ReleaseController => {
16345            shared.release_controller(connection_id);
16346            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16347        }
16348        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16349            shared.require_controller(connection_id, role)?;
16350            let root = require_windows_path(root)?;
16351            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16352            let workspace = shared.register_workspace(workspace_id, root).await?;
16353            Ok(NodeResponse::WorkspaceRegistered { workspace })
16354        }
16355        NodeRequest::CreateStandaloneWorkspace {
16356            workspace_id,
16357            root,
16358            initial_branch,
16359        } => {
16360            shared.require_controller(connection_id, role)?;
16361            let root = require_windows_path(root)?;
16362            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16363            let workspace = shared
16364                .create_standalone_workspace(workspace_id, root, initial_branch)
16365                .await?;
16366            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16367        }
16368        NodeRequest::UnregisterWorkspace { workspace_id } => {
16369            shared.require_controller(connection_id, role)?;
16370            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16371            shared.unregister_workspace(&workspace_id)?;
16372            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16373        }
16374        NodeRequest::CreateWorktree {
16375            source_workspace_id,
16376            workspace_id,
16377            target_root,
16378            branch,
16379            base,
16380        } => {
16381            shared.require_controller(connection_id, role)?;
16382            let target_root = require_windows_path(target_root)?;
16383            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16384            let (worktree, workspace) = shared
16385                .create_worktree(
16386                    source_workspace_id,
16387                    workspace_id,
16388                    target_root,
16389                    branch,
16390                    base,
16391                )
16392                .await?;
16393            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16394        }
16395        NodeRequest::RemoveWorktree {
16396            source_workspace_id,
16397            target_root,
16398        } => {
16399            shared.require_controller(connection_id, role)?;
16400            let native_target_root = require_windows_path(target_root.clone())?;
16401            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16402            let workspace_id = shared
16403                .remove_worktree(source_workspace_id, native_target_root)
16404                .await?;
16405            Ok(NodeResponse::WorktreeRemoved {
16406                target_root,
16407                workspace_id,
16408            })
16409        }
16410        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16411            shared.require_controller(connection_id, role)?;
16412            if !terminal_size.is_valid() {
16413                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16414            }
16415            if let Some(prompt) = initial_prompt.as_deref() {
16416                validate_node_text("spawn initial prompt", prompt)?;
16417            }
16418            match shared
16419                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16420                .await
16421            {
16422                Ok(session) => {
16423                    shared.mark_spawn_provisional(session.clone());
16424                    Ok(NodeResponse::SpawnAccepted { session })
16425                }
16426                Err(err) => {
16427                    tracing::warn!(
16428                        %provider,
16429                        %workspace_id,
16430                        ?mode,
16431                        code = ?err.code,
16432                        message = %err.message,
16433                        "session spawn refused"
16434                    );
16435                    Err(err)
16436                }
16437            }
16438        }
16439        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16440            shared.require_controller(connection_id, role)?;
16441            if !terminal_size.is_valid() {
16442                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16443            }
16444            if let Some(prompt) = initial_prompt.as_deref() {
16445                validate_node_text("spawn initial prompt", prompt)?;
16446            }
16447            let plan = backend_paths::plan_launch_options(
16448                provider.as_str(),
16449                mode,
16450                options.model.is_some(),
16451                options.effort.is_some(),
16452            )
16453            .map_err(|r| {
16454                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16455                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16456            })?;
16457            let selection = spawn_selection(&options)?;
16458            let pty_selection = match plan {
16459                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16460                _ => None,
16461            };
16462            let spawned = SPAWN_SESSION_OPTIONS
16463                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16464                .await;
16465            match spawned {
16466                Ok(session) => {
16467                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16468                        shared
16469                            .pending_acp_options
16470                            .lock()
16471                            .unwrap_or_else(|p| p.into_inner())
16472                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16473                    }
16474                    shared.mark_spawn_provisional(session.clone());
16475                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16476                    Ok(NodeResponse::SpawnAccepted { session })
16477                }
16478                Err(err) => {
16479                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16480                    Err(err)
16481                }
16482            }
16483        }
16484        NodeRequest::SpawnSpec { spec } => {
16485            shared.require_controller(connection_id, role)?;
16486            let receipt = shared.spawn_from_spec(spec).await?;
16487            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16488        }
16489        NodeRequest::SpawnManagedWorktree { request } => {
16490            shared.require_controller(connection_id, role)?;
16491            let receipt = shared.spawn_managed_worktree(request).await?;
16492            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16493        }
16494        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16495            shared.require_controller(connection_id, role)?;
16496            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16497            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16498        }
16499        NodeRequest::CleanupManagedWorktree { lease_id } => {
16500            shared.require_controller(connection_id, role)?;
16501            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16502            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16503        }
16504        NodeRequest::DiscoverHistory { session, limit } => {
16505            controlled_session(shared, connection_id, role, &session)?;
16506            let candidates = shared.discover_history(&session, limit).await?;
16507            Ok(NodeResponse::HistoryDiscovered {
16508                session,
16509                candidates,
16510            })
16511        }
16512        NodeRequest::LoadHistory {
16513            session,
16514            candidate_id,
16515        } => {
16516            controlled_session(shared, connection_id, role, &session)?;
16517            let loaded = shared
16518                .load_history(&session, candidate_id)
16519                .await?;
16520            Ok(NodeResponse::HistoryLoaded {
16521                session,
16522                session_id: loaded.session_id,
16523                message_count: loaded.message_count,
16524                completed_turn_count: loaded.completed_turn_count,
16525            })
16526        }
16527        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16528            shared.require_controller(connection_id, role)?;
16529            let context = shared
16530                .export_context_pack_for_session_record(&record_id, &session, false)
16531                .await?;
16532            Ok(NodeResponse::ContextPackForSessionRecordExported {
16533                record_id,
16534                session,
16535                context,
16536            })
16537        }
16538        NodeRequest::ExportContextPack { session } => {
16539            controlled_session(shared, connection_id, role, &session)?;
16540            let context = shared.export_context_pack(&session).await?;
16541            Ok(NodeResponse::ContextPackExported { context })
16542        }
16543        NodeRequest::ForgetContextPack { context_id } => {
16544            shared.require_controller(connection_id, role)?;
16545            shared.forget_context_pack(&context_id)?;
16546            Ok(NodeResponse::ContextPackForgotten { context_id })
16547        }
16548        NodeRequest::ResolveDurableContextPack { context_id } => {
16549            // Read-only, idempotent, no controller lease required: this lets
16550            // a caller preflight "is this durably exported pack still here"
16551            // without holding the Node's mutation controller.
16552            let context = shared
16553                .context_catalog
16554                .read()
16555                .unwrap_or_else(|poisoned| poisoned.into_inner())
16556                .get(&context_id)
16557                .map(|pack| pack.receipt().clone())
16558                .ok_or_else(|| {
16559                    failure(
16560                        NodeFailureCode::UnknownContextPack,
16561                        "context pack is not present",
16562                    )
16563                })?;
16564            Ok(NodeResponse::DurableContextPackResolved { context })
16565        }
16566        NodeRequest::ReadContextPack { digest } => {
16567            // Read-only fetch of the pack's own bytes, keyed by content digest
16568            // rather than the caller's `SpawnContextId`: same no-controller-lease
16569            // rationale as `ResolveDurableContextPack` above, one step further
16570            // down the same catalog.
16571            let (id, byte_len, bytes) = shared
16572                .context_catalog
16573                .read()
16574                .unwrap_or_else(|poisoned| poisoned.into_inner())
16575                .get_by_digest(&digest)
16576                .map(|pack| {
16577                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16578                })
16579                .ok_or_else(|| {
16580                    failure(
16581                        NodeFailureCode::UnknownContextPack,
16582                        &format!("context pack {digest} not on this node"),
16583                    )
16584                })?;
16585            tracing::debug!(
16586                digest = digest.as_str(),
16587                byte_len,
16588                "read context pack bytes",
16589            );
16590            Ok(NodeResponse::ContextPackBytesRead {
16591                pack: ContextPackBytesRead {
16592                    digest,
16593                    id,
16594                    byte_len,
16595                    bytes,
16596                },
16597            })
16598        }
16599        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16600            let provider = controlled_session(shared, connection_id, role, &session)?;
16601            if let Some(prompt) = initial_prompt.as_deref() {
16602                validate_node_text("resume initial prompt", prompt)?;
16603            }
16604            let runtime_requirement = if initial_prompt.is_some() {
16605                ProviderRuntimeRequirement::ResumeWithPrompt
16606            } else {
16607                ProviderRuntimeRequirement::Resume
16608            };
16609            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16610            let runtime_policy = shared
16611                .admit_provider_runtime(&provider, runtime_requirement)
16612                .await?;
16613            let working_directory = shared.bound_workspace_root(&session)?;
16614            let request = ResumeLaunchRequest {
16615                working_directory,
16616                terminal_size,
16617                initial_prompt,
16618            };
16619            request
16620                .validate()
16621                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16622            let command = shared.prepare_command(ControlCommand::Resume {
16623                instance_id: session.session.instance_id,
16624                target: ResumeTarget::CurrentProvider,
16625                runtime_policy,
16626                request,
16627            });
16628            shared.arm_resume(&session, command.id, runtime_policy)?;
16629            let dispatch = shared.dispatch_envelope(command);
16630            if let Err(error) = dispatch {
16631                shared.clear_armed_resume(&session);
16632                return Err(error);
16633            }
16634            Ok(NodeResponse::Accepted)
16635        }
16636        NodeRequest::RenameSessionRecord {
16637            record_id,
16638            display_name,
16639        } => {
16640            shared.require_controller(connection_id, role)?;
16641            let record = shared.rename_session_record(&record_id, display_name)?;
16642            Ok(NodeResponse::SessionRecordUpdated { record })
16643        }
16644        NodeRequest::SetSessionTask {
16645            record_id,
16646            expected_revision,
16647            target,
16648        } => {
16649            shared.require_controller(connection_id, role)?;
16650            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16651            Ok(NodeResponse::SessionRecordUpdated { record })
16652        }
16653        NodeRequest::IndexProviderSession {
16654            workspace_id,
16655            provider,
16656            identity,
16657            display_name,
16658        } => {
16659            shared.require_controller(connection_id, role)?;
16660            let record = shared.index_provider_session(
16661                workspace_id,
16662                provider,
16663                identity,
16664                display_name,
16665            )?;
16666            Ok(NodeResponse::ProviderSessionIndexed { record })
16667        }
16668        NodeRequest::ResumeSessionRecord {
16669            record_id,
16670            terminal_size,
16671            initial_prompt,
16672        } => {
16673            shared.require_controller(connection_id, role)?;
16674            if let Some(prompt) = initial_prompt.as_deref() {
16675                validate_node_text("managed resume initial prompt", prompt)?;
16676            }
16677            let (record, session) = shared
16678                .resume_session_record(
16679                    &record_id,
16680                    terminal_size,
16681                    initial_prompt,
16682                )
16683                .await?;
16684            Ok(NodeResponse::SessionRecordResumed { record, session })
16685        }
16686        NodeRequest::ForgetSessionRecord { record_id } => {
16687            shared.require_controller(connection_id, role)?;
16688            shared.forget_session_record(&record_id).await?;
16689            Ok(NodeResponse::SessionRecordForgotten { record_id })
16690        }
16691        NodeRequest::Prompt { session, text } => {
16692            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16693            validate_node_text("prompt", &text)?;
16694            shared.require_session_runtime_policy(
16695                &session,
16696                ProviderRuntimeRequirement::SemanticPrompt,
16697            )?;
16698            let framing = prompt_framing(&agent_id);
16699            shared
16700                .dispatch_input_bounded(
16701                    &session,
16702                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16703                )
16704                .await?;
16705            Ok(NodeResponse::Accepted)
16706        }
16707        NodeRequest::Paste { session, text } => {
16708            controlled_session(shared, connection_id, role, &session)?;
16709            validate_node_text("paste", &text)?;
16710            shared.require_session_runtime_policy(
16711                &session,
16712                ProviderRuntimeRequirement::SemanticPrompt,
16713            )?;
16714            shared
16715                .dispatch_input_bounded(
16716                    &session,
16717                    InputAction::InsertDraft(PromptPayload {
16718                        text,
16719                        framing: PromptFraming::BracketedPaste,
16720                    }),
16721                )
16722                .await?;
16723            Ok(NodeResponse::Accepted)
16724        }
16725        NodeRequest::Input { session, text } => {
16726            controlled_session(shared, connection_id, role, &session)?;
16727            validate_node_text("terminal input", &text)?;
16728            shared
16729                .dispatch_input_bounded(
16730                    &session,
16731                    InputAction::TerminalText(TerminalText { text }),
16732                )
16733                .await?;
16734            Ok(NodeResponse::Accepted)
16735        }
16736        NodeRequest::TerminalBytes { session, bytes } => {
16737            controlled_session(shared, connection_id, role, &session)?;
16738            shared.sessionize_spawn(session.session.instance_id);
16739            let action = terminal_bytes_action(bytes)?;
16740            shared
16741                .dispatch_input_bounded(&session, action)
16742                .await?;
16743            Ok(NodeResponse::Accepted)
16744        }
16745        NodeRequest::TerminalControl { session, control } => {
16746            controlled_session(shared, connection_id, role, &session)?;
16747            shared
16748                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16749                .await?;
16750            Ok(NodeResponse::Accepted)
16751        }
16752        NodeRequest::Resize { session, size } => {
16753            controlled_session(shared, connection_id, role, &session)?;
16754            shared.sessionize_spawn(session.session.instance_id);
16755            if !size.is_valid() {
16756                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16757            }
16758            shared.dispatch_resize_bounded(&session, size).await?;
16759            Ok(NodeResponse::Accepted)
16760        }
16761        NodeRequest::Interrupt { session } => {
16762            controlled_session(shared, connection_id, role, &session)?;
16763            shared
16764                .dispatch_input_bounded(
16765                    &session,
16766                    InputAction::TerminalControl(TerminalControl::Interrupt),
16767                )
16768                .await?;
16769            Ok(NodeResponse::Accepted)
16770        }
16771        NodeRequest::Stop { session, force } => {
16772            controlled_session(shared, connection_id, role, &session)?;
16773            shared.sessionize_spawn(session.session.instance_id);
16774            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16775            Ok(NodeResponse::Accepted)
16776        }
16777        NodeRequest::Remove { session } => {
16778            controlled_session(shared, connection_id, role, &session)?;
16779            shared.remove_session(&session).await?;
16780            Ok(NodeResponse::Accepted)
16781        }
16782        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16783            shared.require_controller(connection_id, role)?;
16784            let snapshot = shared.require_acp_session(&session)?;
16785            let interaction = snapshot
16786                .provider
16787                .interactions
16788                .iter()
16789                .find(|interaction| {
16790                    interaction_correlation(
16791                        session.session.instance_id,
16792                        session.session.generation,
16793                        interaction.id.0,
16794                    ) == correlation_id
16795                })
16796                .ok_or_else(|| failure(
16797                    NodeFailureCode::InvalidRequest,
16798                    "unknown interaction correlation",
16799                ))?;
16800            if interaction.status != ProviderInteractionStatus::Pending {
16801                return Err(failure(
16802                    NodeFailureCode::BackendOperationFailed,
16803                    "interaction has already been resolved",
16804                ));
16805            }
16806            response
16807                .validate_for(interaction.interaction_kind)
16808                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16809            let interaction_id = interaction.id;
16810            let response_kind = response.kind();
16811            let response_outcome = response.outcome();
16812            shared.dispatch(ControlCommand::ResolveInteraction {
16813                instance_id: session.session.instance_id,
16814                generation: session.session.generation,
16815                interaction_id,
16816                response,
16817            })?;
16818            // The one line that lets an operator compare what THIS node
16819            // recorded against what the provider itself reports doing --
16820            // measured live on a kimi run where our side resolved `true`
16821            // while the agent reported "rejected" and neither layer had a
16822            // line to line up against the other.
16823            tracing::info!(
16824                node_id = %shared.node_id,
16825                session = ?session.session,
16826                correlation_id = %correlation_id,
16827                interaction_id = interaction_id.0,
16828                response_kind = ?response_kind,
16829                outcome = ?response_outcome,
16830                "interaction resolved",
16831            );
16832            Ok(NodeResponse::Accepted)
16833        }
16834        NodeRequest::SetSessionMode { session, mode_id } => {
16835            shared.require_controller(connection_id, role)?;
16836            shared.require_acp_session(&session)?;
16837            shared.dispatch(ControlCommand::SetSessionMode {
16838                instance_id: session.session.instance_id,
16839                mode_id,
16840            })?;
16841            Ok(NodeResponse::Accepted)
16842        }
16843        NodeRequest::SetSessionConfigOption {
16844            session,
16845            option_id,
16846            value_json,
16847        } => {
16848            shared.require_controller(connection_id, role)?;
16849            shared.require_acp_session(&session)?;
16850            shared.dispatch(ControlCommand::SetSessionConfigOption {
16851                instance_id: session.session.instance_id,
16852                option_id,
16853                value_json,
16854            })?;
16855            Ok(NodeResponse::Accepted)
16856        }
16857        NodeRequest::SetSessionModel { session, model_id } => {
16858            shared.require_controller(connection_id, role)?;
16859            shared.require_acp_session(&session)?;
16860            shared.dispatch(ControlCommand::SetSessionModel {
16861                instance_id: session.session.instance_id,
16862                model_id,
16863            })?;
16864            Ok(NodeResponse::Accepted)
16865        }
16866        NodeRequest::Shutdown => {
16867            shared.require_controller(connection_id, role)?;
16868            shared.begin_shutdown_locked().await?;
16869            Ok(NodeResponse::ShuttingDown)
16870        }
16871    }
16872}
16873
16874fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16875    shared.require_controller(connection_id, role)?;
16876    shared.validate_address(session)
16877}
16878
16879/// Selects the sole history candidate matching `provider_session_id` when
16880/// the managed record carries a verified identity, or the sole candidate in
16881/// the discovered set at all when it does not (see
16882/// `export_context_pack_for_session_record_inner`'s identity match). Either
16883/// way, more than one match refuses instead of guessing which one is exact.
16884fn select_history_candidate(
16885    candidates: &[HistoryCandidateSummary],
16886    provider_session_id: Option<&str>,
16887) -> Result<String, NodeFailure> {
16888    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16889        Some(id) => candidate.session_id_hint == id,
16890        None => true,
16891    });
16892    let candidate = matches.next().ok_or_else(|| {
16893        failure(
16894            NodeFailureCode::SessionRecordConflict,
16895            if provider_session_id.is_some() {
16896                "no history candidate matches the managed provider session"
16897            } else {
16898                "no history candidate exists for the managed session"
16899            },
16900        )
16901    })?;
16902    if matches.next().is_some() {
16903        return Err(failure(
16904            NodeFailureCode::SessionRecordConflict,
16905            if provider_session_id.is_some() {
16906                "history identity is ambiguous for the managed provider session"
16907            } else {
16908                "history is ambiguous without a verified provider session identity"
16909            },
16910        ));
16911    }
16912    Ok(candidate.id.clone())
16913}
16914
16915fn resolved_spawn_profile_summary(
16916    profile: &SpawnProfileDefaults,
16917    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16918) -> Option<SpawnProfileSummary> {
16919    let environment_profile = match profile.environment_profile_id.as_ref() {
16920        None => None,
16921        Some(profile_id) => {
16922            let binding = environment_profiles.get(profile_id)?;
16923            if binding.provider != profile.provider
16924                || binding.native_profile_id(profile.mode).is_none()
16925            {
16926                return None;
16927            }
16928            Some(ResolvedEnvironmentProfileReceipt {
16929                profile_id: binding.id.clone(),
16930                profile_revision: binding.revision.clone(),
16931                network_allowlist: None,
16932                browser_profile_id: None,
16933            })
16934        }
16935    };
16936    Some(SpawnProfileSummary {
16937        id: profile.profile_id.clone(),
16938        revision: profile.revision.clone(),
16939        environment_profile,
16940    })
16941}
16942
16943/// True if `current` is an acceptable version of `expected` at export
16944/// revalidation/commit time.
16945///
16946/// When `allow_clean_detachment` is `false` (the on-demand,
16947/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16948/// be byte-identical to `expected` and still actively bound to `session` —
16949/// the original, unchanged invariant.
16950///
16951/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16952/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16953/// export reacts to produces on its own: `state` Live -> Dormant,
16954/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16955/// advancing. Every other field — including `provider_session`, `context`,
16956/// `bundle`, `task_binding` — must still match exactly. A record that changed
16957/// in any other way (rebind, a different active session, an identity
16958/// conflict, capacity eviction) is rejected either way; this widens nothing
16959/// about which sessions are eligible, only how the record's own expected
16960/// detachment is tolerated mid-export.
16961fn session_record_export_target_matches(
16962    expected: &ManagedSessionRecord,
16963    current: &ManagedSessionRecord,
16964    session: &SessionAddress,
16965    allow_clean_detachment: bool,
16966) -> bool {
16967    if current == expected && current.active_session.as_ref() == Some(session) {
16968        return current.state == ManagedSessionState::Live;
16969    }
16970    allow_clean_detachment
16971        && current.state == ManagedSessionState::Dormant
16972        && current.active_session.is_none()
16973        && current.record_id == expected.record_id
16974        && current.display_name == expected.display_name
16975        && current.provider == expected.provider
16976        && current.mode == expected.mode
16977        && current.workspace_id == expected.workspace_id
16978        && current.canonical_root == expected.canonical_root
16979        && current.provider_session == expected.provider_session
16980        && current.environment_profile == expected.environment_profile
16981        && current.bundle == expected.bundle
16982        && current.context_id == expected.context_id
16983        && current.context == expected.context
16984        && current.exported_context == expected.exported_context
16985        && current.task_binding == expected.task_binding
16986        && current.created_at_unix_ms == expected.created_at_unix_ms
16987        && current.last_error == expected.last_error
16988}
16989
16990fn session_record_context_export_binding_is_exact(
16991    expected: &ManagedSessionRecord,
16992    current: &ManagedSessionRecord,
16993    identity: Option<&ProviderSessionIdentity>,
16994    session: &SessionAddress,
16995    binding: &SessionBinding,
16996    provider: &AgentId,
16997    current_root: &str,
16998    allow_clean_detachment: bool,
16999) -> bool {
17000    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17001        && current.provider_session.as_ref() == identity
17002        && current.workspace_id == session.workspace_id
17003        && provider == &current.provider
17004        && binding.workspace_id == session.workspace_id
17005        && binding.generation == session.session.generation
17006        && binding.record_id.as_ref() == Some(&current.record_id)
17007        && platform::roots_equal(
17008            current_root,
17009            windows_path_text(&current.canonical_root),
17010        )
17011}
17012
17013fn session_record_context_export_source_is_usable(
17014    record: &ManagedSessionRecord,
17015    status: &SessionStatus,
17016    allow_clean_detachment: bool,
17017) -> bool {
17018    (record.state == ManagedSessionState::Live
17019        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17020        && context_pack_source_status_is_usable(status)
17021}
17022
17023fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17024    PromptFraming::BracketedPaste
17025}
17026
17027fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17028    if text.len() > MAX_NODE_TEXT_BYTES {
17029        return Err(failure(
17030            NodeFailureCode::InvalidRequest,
17031            &format!(
17032                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17033                text.len(),
17034            ),
17035        ));
17036    }
17037    Ok(())
17038}
17039
17040fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17041    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17042        return Err(failure(
17043            NodeFailureCode::InvalidRequest,
17044            &format!(
17045                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17046                bytes.len(),
17047            ),
17048        ));
17049    }
17050    Ok(())
17051}
17052
17053fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17054    validate_terminal_bytes(&bytes)?;
17055    Ok(InputAction::TerminalBytes(bytes))
17056}
17057
17058fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17059    if root.is_empty()
17060        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17061        || root.chars().any(char::is_control)
17062    {
17063        return Err(failure(
17064            NodeFailureCode::InvalidWorkspaceRoot,
17065            &format!(
17066                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17067            ),
17068        ));
17069    }
17070    Ok(())
17071}
17072
17073fn canonical_standalone_workspace_candidate(
17074    workspace_id: &WorkspaceId,
17075    root: &str,
17076) -> Result<String, NodeServerError> {
17077    let path = Path::new(root);
17078    match std::fs::symlink_metadata(path) {
17079        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17080            .map(|workspace| workspace.canonical_root),
17081        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17082            if !path.is_absolute() {
17083                return Err(NodeServerError::InvalidWorkspaceRoot {
17084                    workspace_id: workspace_id.clone(),
17085                    path: root.to_owned(),
17086                    message: "path is not absolute".to_owned(),
17087                });
17088            }
17089            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17090                workspace_id: workspace_id.clone(),
17091                path: root.to_owned(),
17092                message: "path has no standalone directory name".to_owned(),
17093            })?;
17094            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17095                workspace_id: workspace_id.clone(),
17096                path: root.to_owned(),
17097                message: "path has no parent directory".to_owned(),
17098            })?;
17099            let canonical = std::fs::canonicalize(parent)
17100                .map(|parent| parent.join(name))
17101                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17102                    workspace_id: workspace_id.clone(),
17103                    path: root.to_owned(),
17104                    message: error.to_string(),
17105                })?;
17106            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17107                NodeServerError::InvalidWorkspaceRoot {
17108                    workspace_id: workspace_id.clone(),
17109                    path: path.to_string_lossy().into_owned(),
17110                    message: "canonical path is not valid Unicode".to_owned(),
17111                }
17112            })?;
17113            let canonical_root = platform::normalize_canonical_root(canonical_root);
17114            validate_workspace_root(workspace_id, &canonical_root)?;
17115            Ok(canonical_root)
17116        }
17117        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17118            workspace_id: workspace_id.clone(),
17119            path: root.to_owned(),
17120            message: error.to_string(),
17121        }),
17122    }
17123}
17124
17125fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17126    let Some(revision) = revision else {
17127        return Ok(());
17128    };
17129    if revision.is_empty()
17130        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17131        || revision.chars().any(char::is_control)
17132    {
17133        return Err(failure(
17134            NodeFailureCode::InvalidRequest,
17135            &format!(
17136                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17137            ),
17138        ));
17139    }
17140    Ok(())
17141}
17142
17143fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17144    let code = match error.kind {
17145        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17146        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17147        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17148        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17149        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17150        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17151        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17152    };
17153    failure(code, &error.message)
17154}
17155
17156fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17157    let code = match error.kind {
17158        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17159        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17160        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17161            NodeFailureCode::BackendOperationFailed
17162        }
17163        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17164            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17165        }
17166    };
17167    failure(code, error.message)
17168}
17169
17170fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17171    let (code, message) = match error.kind {
17172        GitWorktreeErrorKind::Invalid => (
17173            NodeFailureCode::InvalidRequest,
17174            "managed Git input is invalid",
17175        ),
17176        GitWorktreeErrorKind::NotRepository => (
17177            NodeFailureCode::NotGitRepository,
17178            "managed source is not a Git repository",
17179        ),
17180        GitWorktreeErrorKind::Conflict => (
17181            NodeFailureCode::WorktreeConflict,
17182            "managed Git identity conflicts with existing repository state",
17183        ),
17184        GitWorktreeErrorKind::Protected => (
17185            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17186            "managed worktree ownership validation failed",
17187        ),
17188        GitWorktreeErrorKind::Dirty => (
17189            NodeFailureCode::WorktreeDirty,
17190            "managed worktree contains uncommitted changes",
17191        ),
17192        GitWorktreeErrorKind::Locked => (
17193            NodeFailureCode::WorktreeLocked,
17194            "managed worktree is locked",
17195        ),
17196        GitWorktreeErrorKind::Failed => (
17197            NodeFailureCode::BackendOperationFailed,
17198            "managed Git operation failed",
17199        ),
17200    };
17201    NodeFailure { code, message: message.to_owned() }
17202}
17203
17204fn managed_spawn_receipt(
17205    resolved: &ResolvedSpawnSpec,
17206    incarnation_id: NodeIncarnationId,
17207    session: SessionAddress,
17208    lease: ManagedWorktreeLeaseSnapshot,
17209    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17210    bundle: Option<ResolvedBundleReceipt>,
17211    context: Option<ResolvedContextPackReceipt>,
17212) -> ManagedWorktreeSpawnReceipt {
17213    let mut managed = resolved.clone();
17214    managed.target.worktree_id = Some(lease.workspace_id.clone());
17215    ManagedWorktreeSpawnReceipt {
17216        spawn: managed.receipt_with_materialization(
17217            incarnation_id,
17218            session,
17219            environment_profile,
17220            bundle,
17221            context,
17222        ),
17223        lease,
17224    }
17225}
17226
17227fn validate_selected_worktree(
17228    source_root: &str,
17229    selected_root: &str,
17230    worktrees: &[NativeGitWorktreeSnapshot],
17231) -> Result<(), NodeFailure> {
17232    if !worktrees
17233        .iter()
17234        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17235    {
17236        return Err(failure(
17237            NodeFailureCode::WorktreeProtected,
17238            "source workspace is not an authoritative non-bare Git worktree root",
17239        ));
17240    }
17241    if !worktrees.iter().any(|worktree| {
17242        worktree_paths_equal(&worktree.path, selected_root)
17243            && !worktree.is_main
17244            && !worktree.is_bare
17245    }) {
17246        return Err(failure(
17247            NodeFailureCode::WorktreeProtected,
17248            "selected workspace is not an eligible Git worktree in the source repository",
17249        ));
17250    }
17251    Ok(())
17252}
17253
17254fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17255    let code = match error.kind() {
17256        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17257        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17258            NodeFailureCode::RepositoryPathUnsafe
17259        }
17260        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17261        WorkspaceFileReadErrorKind::NotRegularFile => {
17262            NodeFailureCode::RepositoryFileNotRegular
17263        }
17264        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17265            NodeFailureCode::RepositoryFileReadFailed
17266        }
17267        WorkspaceFileReadErrorKind::RevisionConflict => {
17268            NodeFailureCode::RepositoryFileRevisionConflict
17269        }
17270        WorkspaceFileReadErrorKind::AlreadyExists
17271        | WorkspaceFileReadErrorKind::ParentNotFound
17272        | WorkspaceFileReadErrorKind::ParentNotDirectory
17273        | WorkspaceFileReadErrorKind::Canceled => {
17274            NodeFailureCode::RepositoryFileReadFailed
17275        }
17276    };
17277    failure(code, "repository file read failed")
17278}
17279
17280fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17281    let code = match error.kind() {
17282        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17283        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17284            NodeFailureCode::RepositoryPathUnsafe
17285        }
17286        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17287        WorkspaceFileReadErrorKind::NotRegularFile => {
17288            NodeFailureCode::RepositoryFileNotRegular
17289        }
17290        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17291            NodeFailureCode::RepositoryFileWriteFailed
17292        }
17293        WorkspaceFileReadErrorKind::RevisionConflict => {
17294            NodeFailureCode::RepositoryFileRevisionConflict
17295        }
17296        WorkspaceFileReadErrorKind::AlreadyExists
17297        | WorkspaceFileReadErrorKind::ParentNotFound
17298        | WorkspaceFileReadErrorKind::ParentNotDirectory
17299        | WorkspaceFileReadErrorKind::Canceled => {
17300            NodeFailureCode::RepositoryFileWriteFailed
17301        }
17302    };
17303    failure(code, "repository file write failed")
17304}
17305
17306async fn settle_workspace_entry_create<T>(
17307    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17308    commit_state: Arc<AtomicU8>,
17309    deadline: Duration,
17310    timeout_message: &'static str,
17311    task_failure_message: &'static str,
17312) -> Result<T, NodeFailure> {
17313    let joined = match timeout(deadline, &mut task).await {
17314        Ok(joined) => joined,
17315        Err(_) => {
17316            if commit_state
17317                .compare_exchange(
17318                    WORKSPACE_ENTRY_CREATE_PENDING,
17319                    WORKSPACE_ENTRY_CREATE_CANCELED,
17320                    Ordering::AcqRel,
17321                    Ordering::Acquire,
17322                )
17323                .is_ok()
17324            {
17325                return Err(failure(
17326                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17327                    timeout_message,
17328                ));
17329            }
17330            debug_assert_eq!(
17331                commit_state.load(Ordering::Acquire),
17332                WORKSPACE_ENTRY_CREATE_COMMITTING,
17333            );
17334            task.await
17335        }
17336    };
17337    joined
17338        .map_err(|_| {
17339            failure(
17340                NodeFailureCode::RepositoryEntryCreateFailed,
17341                task_failure_message,
17342            )
17343        })?
17344        .map_err(workspace_entry_create_failure)
17345}
17346
17347fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17348    let code = match error.kind() {
17349        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17350        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17351            NodeFailureCode::RepositoryPathUnsafe
17352        }
17353        WorkspaceFileReadErrorKind::AlreadyExists => {
17354            NodeFailureCode::RepositoryEntryAlreadyExists
17355        }
17356        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17357            NodeFailureCode::RepositoryParentNotFound
17358        }
17359        WorkspaceFileReadErrorKind::ParentNotDirectory
17360        | WorkspaceFileReadErrorKind::NotRegularFile => {
17361            NodeFailureCode::RepositoryParentNotDirectory
17362        }
17363        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17364            NodeFailureCode::RepositoryEntryCreateFailed
17365        }
17366        WorkspaceFileReadErrorKind::RevisionConflict => {
17367            NodeFailureCode::RepositoryEntryCreateFailed
17368        }
17369        WorkspaceFileReadErrorKind::Canceled => {
17370            NodeFailureCode::RepositoryEntryCreateTimedOut
17371        }
17372    };
17373    failure(code, "repository entry create failed")
17374}
17375
17376fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17377    let value = digest(&SHA256, bytes)
17378        .as_ref()
17379        .iter()
17380        .map(|byte| format!("{byte:02x}"))
17381        .collect();
17382    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17383}
17384
17385fn managed_spawn_request_digest_v2(
17386    request: &ManagedWorktreeSpawnRequestV2,
17387) -> Result<String, NodeFailure> {
17388    let encoded = serde_json::to_vec(request).map_err(|_| {
17389        failure(
17390            NodeFailureCode::InvalidRequest,
17391            "managed worktree V2 request could not be canonicalized",
17392        )
17393    })?;
17394    let mut value = String::with_capacity(71);
17395    value.push_str("sha256:");
17396    for byte in digest(&SHA256, &encoded).as_ref() {
17397        use std::fmt::Write as _;
17398        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17399    }
17400    Ok(value)
17401}
17402
17403fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17404    let code = match error {
17405        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17406        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17407        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17408        NativeSessionPreviewError::UnsupportedProvider
17409        | NativeSessionPreviewError::PreviewUnavailable
17410        | NativeSessionPreviewError::SessionNotFound
17411        | NativeSessionPreviewError::AmbiguousSession => {
17412            NodeFailureCode::BackendOperationFailed
17413        }
17414    };
17415    failure(code, "native session preview failed")
17416}
17417
17418fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17419    let code = match error.kind() {
17420        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17421        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17422    };
17423    failure(code, "host directory browse failed")
17424}
17425
17426fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17427    let code = match error {
17428        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17429        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17430        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17431            NodeFailureCode::DeliveryStageConflict
17432        }
17433        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17434        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17435            NodeFailureCode::DeliveryChunkOutOfOrder
17436        }
17437        DeliveryStoreError::BlobDigestMismatch => {
17438            NodeFailureCode::DeliveryBlobDigestMismatch
17439        }
17440        DeliveryStoreError::BundleDigestMismatch => {
17441            NodeFailureCode::DeliveryBundleDigestMismatch
17442        }
17443        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17444        DeliveryStoreError::Unavailable
17445        | DeliveryStoreError::Corrupt
17446        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17447    };
17448    failure(code, "delivery operation failed")
17449}
17450
17451fn validate_workspace_root(
17452    workspace_id: &WorkspaceId,
17453    canonical_root: &str,
17454) -> Result<(), NodeServerError> {
17455    #[cfg(windows)]
17456    if canonical_root.starts_with(r"\\") {
17457        return Err(NodeServerError::InvalidWorkspaceRoot {
17458            workspace_id: workspace_id.clone(),
17459            path: canonical_root.to_owned(),
17460            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17461        });
17462    }
17463    if !platform::workspace_root_supported(canonical_root)
17464        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17465    {
17466        return Err(NodeServerError::InvalidWorkspaceRoot {
17467            workspace_id: workspace_id.clone(),
17468            path: canonical_root.to_owned(),
17469            message: "canonical path must be absolute, bounded, and free of control characters"
17470                .to_owned(),
17471        });
17472    }
17473    Ok(())
17474}
17475
17476fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17477    NodeFailure {
17478        code,
17479        message: node_failure_category(code).to_owned(),
17480    }
17481}
17482
17483fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17484    let code = match error {
17485        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17486        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17487        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17488        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17489        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17490        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17491        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17492        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17493        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17494    };
17495    failure(code, "harness MCP proxy operation failed")
17496}
17497
17498/// Numbers each harness-MCP overlay this process constructs, in
17499/// construction order, so two overlays built back to back in one node process
17500/// still get distinct trace files.
17501static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17502
17503/// Builds the optional trace environment entry of one harness-MCP overlay.
17504/// The caller's launch description names both the entry and the node-process
17505/// variable that opts in: when that variable holds a non-empty directory, the
17506/// entry names a trace file under it. Returns `None` when the launch has no
17507/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17508/// the directory; that is the operator's job.
17509fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17510    let trace = launch.trace.as_ref()?;
17511    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17512    if trace_dir.is_empty() {
17513        return None;
17514    }
17515    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17516    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17517    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17518}
17519
17520fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17521    let remaining = deadline.saturating_duration_since(Instant::now());
17522    if remaining.is_zero() {
17523        Err(failure(
17524            NodeFailureCode::SpawnDeadlineExceeded,
17525            "spawn deadline elapsed",
17526        ))
17527    } else {
17528        Ok(remaining)
17529    }
17530}
17531
17532fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17533    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17534    match deadline {
17535        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17536        None => Ok(bounded),
17537    }
17538}
17539
17540fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17541    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17542        failure(
17543            NodeFailureCode::SpawnDeadlineExceeded,
17544            "spawn dispatch exceeded the spawn deadline",
17545        )
17546    } else {
17547        error
17548    }
17549}
17550
17551fn spawn_runtime_capabilities(
17552    capabilities: &SpawnRequiredCapabilities,
17553) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17554    capabilities
17555        .iter()
17556        .map(|capability| match capability.as_str() {
17557            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17558            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17559            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17560            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17561                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17562            }
17563            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17564            _ => Err(failure(
17565                NodeFailureCode::UnsupportedSpawnCapability,
17566                "spawn required capability is unknown",
17567            )),
17568        })
17569        .collect()
17570}
17571
17572fn persistence_failure(_error: io::Error) -> NodeFailure {
17573    NodeFailure {
17574        code: NodeFailureCode::BackendOperationFailed,
17575        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17576    }
17577}
17578
17579fn node_failure_category(code: NodeFailureCode) -> &'static str {
17580    match code {
17581        NodeFailureCode::InvalidRequest => "invalid-request",
17582        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17583        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17584        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17585        NodeFailureCode::ReservationConflict => "reservation-conflict",
17586        NodeFailureCode::ReservationExpired => "reservation-expired",
17587        NodeFailureCode::BindingMismatch => "binding-mismatch",
17588        NodeFailureCode::NotActivated => "not-activated",
17589        NodeFailureCode::CallNotFound => "call-not-found",
17590        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17591        NodeFailureCode::ResponseTooLarge => "response-too-large",
17592        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17593        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17594        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17595        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17596        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17597        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17598        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17599        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17600        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17601        NodeFailureCode::Unauthorized => "unauthorized",
17602        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17603        NodeFailureCode::ControllerBusy => "controller-busy",
17604        NodeFailureCode::ControllerRequired => "controller-required",
17605        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17606        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17607        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17608        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17609        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17610        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17611        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17612        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17613        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17614        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17615        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17616        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17617        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17618        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17619        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17620        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17621        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17622        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17623        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17624        NodeFailureCode::GitReadFailed => "git-read-failed",
17625        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17626        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17627        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17628        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17629        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17630        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17631        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17632        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17633        NodeFailureCode::TurnInFlight => "turn-in-flight",
17634        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17635        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17636        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17637            "unsupported-network-allowlist-mapping"
17638        }
17639        NodeFailureCode::BrowserStationProbeUnavailable => {
17640            "browser-station-probe-unavailable"
17641        }
17642        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17643        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17644        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17645            "environment-profile-binding-mismatch"
17646        }
17647        NodeFailureCode::UnknownBundle => "unknown-bundle",
17648        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17649        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17650        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17651        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17652        NodeFailureCode::ContextPackMaterializationFailed => {
17653            "context-pack-materialization-failed"
17654        }
17655        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17656        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17657        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17658        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17659        NodeFailureCode::LastWorkspace => "last-workspace",
17660        NodeFailureCode::NotGitRepository => "not-git-repository",
17661        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17662        NodeFailureCode::WorktreeProtected => "worktree-protected",
17663        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17664        NodeFailureCode::WorktreeLocked => "worktree-locked",
17665        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17666        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17667        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17668        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17669            "managed-worktree-profile-revision-mismatch"
17670        }
17671        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17672        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17673            "standalone-workspace-recovery-required"
17674        }
17675        NodeFailureCode::UnknownSession => "unknown-session",
17676        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17677        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17678        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17679        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17680        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17681        NodeFailureCode::WorkspaceRegistrationRequired => {
17682            "workspace-registration-required"
17683        }
17684        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17685        NodeFailureCode::StaleGeneration => "stale-generation",
17686        NodeFailureCode::BackendBusy => "backend-busy",
17687        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17688        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17689        NodeFailureCode::ShuttingDown => "shutting-down",
17690    }
17691}
17692
17693fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17694    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17695}
17696
17697fn durable_state_load_error(error: io::Error) -> NodeServerError {
17698    let category = match session_registry::state_load_refusal(&error) {
17699        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17700            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17701        }
17702        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17703            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17704        }
17705        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17706            DURABLE_STATE_CONFLICT_ERROR
17707        }
17708        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17709    };
17710    durable_state_server_error(error, category)
17711}
17712
17713fn unix_time_ms() -> u64 {
17714    std::time::SystemTime::now()
17715        .duration_since(std::time::UNIX_EPOCH)
17716        .unwrap_or_default()
17717        .as_millis()
17718        .min(u64::MAX as u128) as u64
17719}
17720
17721fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17722    if record.provider_session.is_some() {
17723        ManagedSessionState::Dormant
17724    } else {
17725        ManagedSessionState::Unavailable
17726    }
17727}
17728
17729fn active_record_state(
17730    identity_admitted: bool,
17731    provider_session_is_known: bool,
17732) -> ManagedSessionState {
17733    if identity_admitted && !provider_session_is_known {
17734        ManagedSessionState::IdentityPending
17735    } else {
17736        ManagedSessionState::Live
17737    }
17738}
17739
17740fn managed_worktree_record_holders(
17741    records: &[ManagedSessionRecord],
17742) -> Vec<(SessionRecordId, WorkspaceId)> {
17743    records
17744        .iter()
17745        .filter(|record| record.provider_session.is_some())
17746        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17747        .collect()
17748}
17749
17750fn managed_spawn_record_ownership(
17751    binding: &SessionBinding,
17752    effective_runtime_policy: ProviderRuntimePolicy,
17753) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17754    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17755    if effective_runtime_policy.provider_session_identity {
17756        (binding.record_id.clone(), None)
17757    } else {
17758        (None, binding.record_id.clone())
17759    }
17760}
17761
17762#[derive(Debug, Error)]
17763pub enum NodeServerError {
17764    #[error("node endpoint must be a bounded absolute local transport path")]
17765    InvalidEndpoint,
17766    #[error("node access token must contain 1..=4096 bytes")]
17767    InvalidAccessToken,
17768    #[error("node HTTP observer must listen on a loopback address: {0}")]
17769    InvalidApiListen(std::net::SocketAddr),
17770    #[error("node browser bridge must listen on a loopback address: {0}")]
17771    InvalidBridgeListen(std::net::SocketAddr),
17772    #[error("node browser bridge token must contain 1..=4096 bytes")]
17773    InvalidBridgeToken,
17774    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17775    BridgeUnderlayRequiresBridgeListen,
17776    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17777    BridgeUnderlayRequiresBridgeToken,
17778    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17779    InvalidBridgeUnderlayToken,
17780    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17781    InvalidBridgeUnderlayKey,
17782    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17783    InvalidCallHome(std::net::SocketAddr),
17784    #[error("node could not reach its call-home relay: {0}")]
17785    CallHomeTransport(#[source] std::io::Error),
17786    #[error("node durable state path must be an absolute file path: {0}")]
17787    InvalidStatePath(String),
17788    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17789    InvalidHarnessMcpHelper,
17790    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17791    InvalidNetworkAllowlistCatalogPath(String),
17792    #[error("network allowlist catalog could not be read: {0}")]
17793    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17794    #[error("network allowlist catalog line {line}: {message}")]
17795    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17796    #[error("network allowlist catalog: {0}")]
17797    InvalidNetworkAllowlistCatalog(String),
17798    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17799    NetworkAllowlistCatalogCapacity { max: usize },
17800    #[error("the local state directory is unavailable; supply an explicit state path")]
17801    LocalStateDirectoryUnavailable,
17802    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17803    LocalRuntimeDirectoryUnavailable,
17804    #[error("node durable state failed: {0}")]
17805    DurableState(io::Error),
17806    #[error("node requires at least one configured workspace")]
17807    NoWorkspaces,
17808    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17809    InvalidWorkspaceRoot {
17810        workspace_id: WorkspaceId,
17811        path: String,
17812        message: String,
17813    },
17814    #[error("duplicate workspace ID: {0}")]
17815    DuplicateWorkspaceId(WorkspaceId),
17816    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17817    DuplicateWorkspaceRoot {
17818        first: WorkspaceId,
17819        second: WorkspaceId,
17820        root: String,
17821    },
17822    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17823    ManagedWorktreeProfileRequired(WorkspaceId),
17824    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17825    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17826    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17827    DuplicateManagedWorktreeProfile {
17828        workspace_id: WorkspaceId,
17829        profile_id: WorktreeProfileId,
17830    },
17831    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17832    ManagedWorktreeProfileCapacity {
17833        workspace_id: WorkspaceId,
17834        max: usize,
17835    },
17836    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17837    InvalidManagedWorktreeProfile {
17838        workspace_id: WorkspaceId,
17839        profile_id: WorktreeProfileId,
17840        message: String,
17841    },
17842    #[error("duplicate node environment profile: {0}")]
17843    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17844    #[error("node environment profile provider is unavailable: {0}")]
17845    EnvironmentProfileProviderUnavailable(AgentId),
17846    #[error("node environment profile capacity is {max}")]
17847    EnvironmentProfileCapacity { max: usize },
17848    #[error("node environment profiles reuse native profile ID '{0}'")]
17849    DuplicateNativeEnvironmentProfile(String),
17850    #[error("node native environment profile is invalid: {0}")]
17851    NativeEnvironmentProfile(String),
17852    #[error("node session-environment materialization root must be absolute")]
17853    InvalidSessionEnvironmentRoot,
17854    #[error("node session-environment materializer is unavailable")]
17855    SessionEnvironmentMaterializerRequired,
17856    #[error("node session-environment materializer failed to initialize")]
17857    SessionEnvironmentMaterializer,
17858    #[error("node bundle catalog is invalid: {0}")]
17859    BundleCatalog(String),
17860    #[error("node delivery store failed to initialize: {0}")]
17861    DeliveryStore(String),
17862    #[error("node context pack store failed to initialize")]
17863    ContextPackStore,
17864    #[error("active agent registry failed: {0}")]
17865    Registry(String),
17866    #[error("node provider contract manifest is invalid: {0}")]
17867    ProviderContractManifest(String),
17868    #[error("named pipe I/O failed: {0}")]
17869    Io(#[from] io::Error),
17870    #[error("node HTTP observer failed: {0}")]
17871    HttpApi(io::Error),
17872    #[error("node browser bridge failed: {0}")]
17873    BridgeApi(io::Error),
17874    #[error(transparent)]
17875    Frame(#[from] FrameError),
17876    #[error("node handshake failed: {0}")]
17877    Handshake(String),
17878    #[error("node authentication frame was not received before the bounded deadline")]
17879    AuthenticationTimedOut,
17880    #[error("node authentication primitive failed: {0}")]
17881    Authentication(String),
17882    #[error("node incarnation identity generation failed: {0}")]
17883    IncarnationIdentity(String),
17884    #[error("node pre-authentication limiter is closed")]
17885    PreauthClosed,
17886    #[error("node authenticated connection limit was reached")]
17887    AuthenticatedConnectionLimit,
17888    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17889    SignalStreamClosed,
17890    #[error("node stop-all dispatch failed: {0:?}")]
17891    ShutdownDispatch(NodeFailure),
17892    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17893    ShutdownTimedOut { active_native_sessions: usize },
17894}
17895
17896impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17897    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17898        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17899        match error {
17900            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17901            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17902            E::InvalidEntry { line, message } => {
17903                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17904            }
17905            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17906            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17907        }
17908    }
17909}
17910
17911#[cfg(test)]
17912mod agent_stream_blocked_tests {
17913    use super::*;
17914    use gate4agent_types::{
17915        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17916        ProviderSource,
17917    };
17918
17919    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17920        ProviderSource {
17921            family,
17922            binding: AdapterBinding::new(
17923                AdapterId::new(adapter).unwrap(),
17924                "fixture/v1",
17925                AdapterVerification::SyntheticFixture,
17926            )
17927            .unwrap(),
17928        }
17929    }
17930
17931    fn provider_control_event_at(
17932        family: AdapterFamily,
17933        adapter: &str,
17934        source_sequence: u64,
17935        provider_event: ProviderEvent,
17936    ) -> ControlEvent {
17937        ControlEvent {
17938            sequence: 41,
17939            command_id: None,
17940            instance_id: AgentInstanceId(7),
17941            generation: SessionGeneration(3),
17942            event: ControlEventKind::ProviderEvent {
17943                sequence: 8,
17944                source: provider_source(family, adapter),
17945                source_sequence,
17946                event: provider_event,
17947            },
17948        }
17949    }
17950
17951    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17952        provider_control_event_at(family, "fixture", 9, provider_event)
17953    }
17954
17955    fn denied_host_request(
17956        by: ProviderHostDecisionAuthority,
17957        reason: Option<String>,
17958    ) -> ControlEvent {
17959        provider_control_event_at(
17960            AdapterFamily::Acp,
17961            "claude-code",
17962            2,
17963            ProviderEvent::HostRequestObserved {
17964                method: "terminal/create".to_owned(),
17965                params_json: String::new(),
17966                decision: ProviderHostRequestDecision::Denied { by },
17967                outcome: ProviderHostRequestOutcome::Executed,
17968                reason,
17969            },
17970        )
17971    }
17972
17973    fn blocked(
17974        event: &ControlEvent,
17975    ) -> (
17976        Option<String>,
17977        String,
17978        BlockAuthorityV1,
17979        Option<String>,
17980        String,
17981        Option<String>,
17982    ) {
17983        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17984        chunk.validate().expect("minted chunk must validate");
17985        let AgentStreamChunkKindV1::Blocked {
17986            correlation_id,
17987            tool_class,
17988            authority,
17989            reason_kind,
17990            reason,
17991            help,
17992        } = chunk.kind
17993        else {
17994            panic!("expected AgentStreamChunkKindV1::Blocked");
17995        };
17996        (correlation_id, tool_class, authority, reason_kind, reason, help)
17997    }
17998
17999    /// Pins the fleet and every adapter each member declares: the fleet is
18000    /// exactly what `active_registry` says, and no member declares a hook
18001    /// adapter (lifecycle hooks are retired).
18002    #[test]
18003    fn the_enabled_provider_matrix_is_exact() {
18004        let registry = active_registry().unwrap();
18005        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18006        assert_eq!(
18007            providers
18008                .iter()
18009                .map(|contract| contract.provider.as_str())
18010                .collect::<Vec<_>>(),
18011            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18012            vec!["shell", "claude", "codex", "grok", "kimi"],
18013        );
18014        assert!(adapters.iter().all(|contract| {
18015            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18016        }));
18017    }
18018
18019    /// A gate-denied host request, WITH its reason text carried all the way
18020    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18021    #[test]
18022    fn gate_denied_host_request_mints_a_blocked_chunk() {
18023        let gate_text =
18024            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18025        let event = denied_host_request(
18026            ProviderHostDecisionAuthority::Gate,
18027            Some(gate_text.to_owned()),
18028        );
18029        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18030        assert_eq!(chunk.source_sequence, 2);
18031        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18032        assert_eq!(authority, BlockAuthorityV1::HostGate);
18033        assert_eq!(reason, gate_text);
18034        assert_eq!(correlation_id, None);
18035        assert_eq!(reason_kind, None);
18036        assert_eq!(help, None);
18037        assert_eq!(tool_class, "Write");
18038    }
18039
18040    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.";
18041
18042    /// The owner's classifier sample, verbatim, arriving as a
18043    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18044    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18045    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18046    /// and the guidance tail as `help`.
18047    #[test]
18048    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18049        let event = provider_control_event(
18050            AdapterFamily::Acp,
18051            ProviderEvent::ToolCompleted {
18052                id: "toolu_9".to_owned(),
18053                output: CLASSIFIER_SAMPLE.to_owned(),
18054                is_error: true,
18055                duration_ms: None,
18056                agent_id: None,
18057                non_execution_kind: Some("permission-rule".to_owned()),
18058            },
18059        );
18060        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18061        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18062        assert_eq!(reason, "Blocked by classifier");
18063        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18064        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18065        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18066    }
18067
18068    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18069    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18070    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18071    /// vendor code as `reason_kind`.
18072    #[test]
18073    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18074        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.";
18075        let event = provider_control_event(
18076            AdapterFamily::Acp,
18077            ProviderEvent::SessionEnded {
18078                result: "JSON-RPC error -32603: Internal error".to_owned(),
18079                cost_usd: None,
18080                is_error: true,
18081                stop_reason: Some(ProviderStopReason::ProviderError {
18082                    code: -32603,
18083                    message: message.to_owned(),
18084                    vendor_code: Some("usageLimitExceeded".to_owned()),
18085                }),
18086            },
18087        );
18088        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18089        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18090        assert_eq!(reason, message);
18091        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18092        assert_eq!(tool_class, "Session");
18093    }
18094
18095    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18096    /// `authority: ProviderRefusal`.
18097    #[test]
18098    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18099        let event = provider_control_event(
18100            AdapterFamily::Acp,
18101            ProviderEvent::SessionEnded {
18102                result: "refusal".to_owned(),
18103                cost_usd: None,
18104                is_error: true,
18105                stop_reason: Some(ProviderStopReason::Refusal),
18106            },
18107        );
18108        let (_, _, authority, _, _, _) = blocked(&event);
18109        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18110    }
18111
18112    /// A failed tool completion with no typed `non_execution_kind` -- or one
18113    /// that only says the turn stopped -- is a plain execution failure, never a
18114    /// block; nor is an agent's own narrative claiming one.
18115    #[test]
18116    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18117        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18118            let event = provider_control_event(
18119                AdapterFamily::Acp,
18120                ProviderEvent::ToolCompleted {
18121                    id: "toolu_1".to_owned(),
18122                    output: "command exited with status 1".to_owned(),
18123                    is_error: true,
18124                    duration_ms: Some(12),
18125                    agent_id: None,
18126                    non_execution_kind,
18127                },
18128            );
18129            assert!(agent_stream_chunk(&event).is_none());
18130        }
18131        let narrative = provider_control_event(
18132            AdapterFamily::Acp,
18133            ProviderEvent::ToolCompleted {
18134                id: "toolu_2".to_owned(),
18135                output: "the harness blocked the write, approval needed".to_owned(),
18136                is_error: false,
18137                duration_ms: Some(3),
18138                agent_id: None,
18139                non_execution_kind: None,
18140            },
18141        );
18142        assert!(agent_stream_chunk(&narrative).is_none());
18143    }
18144
18145    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18146    /// execution outcome: a granted request that failed while running was
18147    /// still authorized.
18148    #[test]
18149    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18150        for (decision, outcome) in [
18151            (
18152                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18153                ProviderHostRequestOutcome::Executed,
18154            ),
18155            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18156            (
18157                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18158                ProviderHostRequestOutcome::Failed {
18159                    error: "terminal/create spawn failed: os error 3".to_owned(),
18160                },
18161            ),
18162        ] {
18163            let event = provider_control_event_at(
18164                AdapterFamily::Acp,
18165                "claude-code",
18166                3,
18167                ProviderEvent::HostRequestObserved {
18168                    method: "fs/read_text_file".to_owned(),
18169                    params_json: String::new(),
18170                    decision: decision.clone(),
18171                    outcome,
18172                    reason: None,
18173                },
18174            );
18175            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18176        }
18177    }
18178
18179    /// A deferred `session/request_permission` resolved by an operator or an
18180    /// expired deadline carries no reason text -- the chunk falls back to a
18181    /// categorical sentence naming the authority rather than inventing detail.
18182    #[test]
18183    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18184        for (by, expected_authority, expected_reason) in [
18185            (
18186                ProviderHostDecisionAuthority::Operator,
18187                BlockAuthorityV1::Operator,
18188                "declined by the operator",
18189            ),
18190            (
18191                ProviderHostDecisionAuthority::DeadlinePolicy,
18192                BlockAuthorityV1::HostDeadline,
18193                "denied by host policy after the operator deadline expired",
18194            ),
18195        ] {
18196            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18197            assert_eq!(authority, expected_authority);
18198            assert_eq!(reason, expected_reason);
18199        }
18200    }
18201
18202    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18203    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18204    /// outright rather than truncating.
18205    #[test]
18206    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18207        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18208        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18209            ProviderHostDecisionAuthority::Policy,
18210            Some(oversized),
18211        ));
18212        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18213    }
18214}
18215
18216#[cfg(test)]
18217mod standalone_capability_platform_tests {
18218    use super::*;
18219
18220    #[test]
18221    fn standalone_workspace_capability_requires_windows_identity_guards() {
18222        let capabilities = baseline_capabilities().unwrap();
18223        let advertised = capabilities.iter().any(|capability| {
18224            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18225        });
18226        assert_eq!(advertised, cfg!(windows));
18227
18228        let request = NodeRequest::CreateStandaloneWorkspace {
18229            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18230            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18231            initial_branch: None,
18232        };
18233        assert_eq!(
18234            request_uses_unnegotiated_capability(&request, &capabilities),
18235            !cfg!(windows),
18236        );
18237    }
18238}
18239
18240
18241#[cfg(test)]
18242mod station_knobs_resolve_tests {
18243    use super::*;
18244    use gate4agent_catalog::EnvMutation;
18245    use gate4agent_runtime_native::{
18246        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18247        NativeLaunchProfileId,
18248    };
18249    use std::ffi::OsString;
18250    use std::sync::Arc;
18251
18252    struct EmptyEnvironmentResolver;
18253
18254    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18255        fn resolve_child_environment(
18256            &self,
18257        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18258            Ok(vec![EnvMutation {
18259                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18260                value: None,
18261            }])
18262        }
18263    }
18264
18265    fn agent(value: &str) -> AgentId {
18266        AgentId::new(value).unwrap()
18267    }
18268
18269    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18270        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18271        let profile_revision =
18272            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18273        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18274            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18275            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18276            provider: agent("claude"),
18277            mode: SessionMode::Pty,
18278            terminal_size: gate4agent_types::TerminalSize {
18279                rows: 24,
18280                columns: 80,
18281            },
18282            prompt: None,
18283            bundle_id: None,
18284            context_id: None,
18285            environment_profile_id: Some(profile_id.clone()),
18286        };
18287        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18288        let catalog = active_registry().unwrap();
18289        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18290        let control = runtime.native_launch_profile_control();
18291        let node_profile = NodeEnvironmentProfile::new(
18292            profile_id.clone(),
18293            profile_revision,
18294            agent("claude"),
18295            [NativeLaunchProfile::new(
18296                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18297                agent("claude"),
18298                TransportKind::Pty,
18299                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18300                Arc::new(EmptyEnvironmentResolver),
18301            )
18302            .unwrap()],
18303        )
18304        .unwrap();
18305        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18306        for native_profile in native_profiles {
18307            runtime.upsert_native_launch_profile(native_profile).unwrap();
18308        }
18309        let workspace = WorkspaceConfig::new(
18310            WorkspaceId::new("primary").unwrap(),
18311            std::env::current_dir().unwrap(),
18312        )
18313        .unwrap();
18314        let shared = NodeShared::new_with_incarnation(
18315            handle,
18316            "fixture-token".to_owned(),
18317            NodeId::new("node-station-knobs-test").unwrap(),
18318            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18319            vec![workspace],
18320            vec![agent("claude")],
18321            ProviderRuntimeStatuses::default(),
18322            None,
18323            Vec::new(),
18324            Vec::new(),
18325            spawn_profiles,
18326            Some(control),
18327            None,
18328            None,
18329            None,
18330            Vec::new(),
18331            Vec::new(),
18332            Vec::new(),
18333            Vec::new(),
18334            None,
18335            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18336        );
18337        shared
18338            .environment_profiles
18339            .write()
18340            .unwrap_or_else(|poisoned| poisoned.into_inner())
18341            .insert(binding.id.clone(), binding);
18342        (shared, spawn_defaults)
18343    }
18344
18345    /// Empty-default catalog refuses unknown network_allowlist ids.
18346    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18347    #[test]
18348    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18349        let (shared, defaults) = station_knobs_fixture();
18350        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18351        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18352        let spec = crate::protocol::SpawnSpec {
18353            target: crate::protocol::SpawnTarget {
18354                node_id: shared.node_id.clone(),
18355                workspace_id: WorkspaceId::new("primary").unwrap(),
18356                worktree_id: None,
18357            },
18358            profile_id: defaults.profile_id.clone(),
18359            expected_profile_revision: defaults.revision.clone(),
18360            overrides: crate::protocol::SpawnOverrides {
18361                network_allowlist: Some(unknown),
18362                browser_profile_id: Some(browser),
18363                ..crate::protocol::SpawnOverrides::default()
18364            },
18365            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18366            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18367                "station-knobs-unknown-allowlist",
18368            )
18369            .unwrap(),
18370            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18371        };
18372        let resolved = spec.resolve(&defaults).unwrap();
18373        let failure = shared
18374            .resolve_environment_profile(&resolved)
18375            .expect_err("unknown allowlist must refuse");
18376        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18377    }
18378
18379    /// Registered network_allowlist (+ browser_profile_id when feature off)
18380    /// echo onto the env-profile receipt. Empty catalog still allows omit
18381    /// (None). Empty/whitespace refuse is type-level. Feature-off
18382    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18383    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18384    #[test]
18385    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18386        let (shared, defaults) = station_knobs_fixture();
18387        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18388        #[cfg(not(feature = "dig2-station-probe"))]
18389        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18390        shared
18391            .network_allowlist_catalog
18392            .write()
18393            .unwrap_or_else(|poisoned| poisoned.into_inner())
18394            .insert_id(allowlist.clone())
18395            .unwrap();
18396        let spec = crate::protocol::SpawnSpec {
18397            target: crate::protocol::SpawnTarget {
18398                node_id: shared.node_id.clone(),
18399                workspace_id: WorkspaceId::new("primary").unwrap(),
18400                worktree_id: None,
18401            },
18402            profile_id: defaults.profile_id.clone(),
18403            expected_profile_revision: defaults.revision.clone(),
18404            overrides: crate::protocol::SpawnOverrides {
18405                network_allowlist: Some(allowlist.clone()),
18406                #[cfg(not(feature = "dig2-station-probe"))]
18407                browser_profile_id: Some(browser.clone()),
18408                ..crate::protocol::SpawnOverrides::default()
18409            },
18410            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18411            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18412                .unwrap(),
18413            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18414        };
18415        let resolved = spec.resolve(&defaults).unwrap();
18416        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18417        #[cfg(not(feature = "dig2-station-probe"))]
18418        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18419        let receipt = shared
18420            .resolve_environment_profile(&resolved)
18421            .expect("resolve ok")
18422            .expect("env profile present");
18423        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18424        #[cfg(not(feature = "dig2-station-probe"))]
18425        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18426        #[cfg(feature = "dig2-station-probe")]
18427        assert_eq!(receipt.browser_profile_id, None);
18428        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18429
18430        let omitted = crate::protocol::SpawnSpec {
18431            overrides: crate::protocol::SpawnOverrides::default(),
18432            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18433                .unwrap(),
18434            ..spec
18435        };
18436        let resolved_omit = omitted.resolve(&defaults).unwrap();
18437        let bare = shared
18438            .resolve_environment_profile(&resolved_omit)
18439            .expect("resolve ok")
18440            .expect("env profile present");
18441        assert_eq!(bare.network_allowlist, None);
18442        assert_eq!(bare.browser_profile_id, None);
18443    }
18444
18445    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18446    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18447    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18448    /// unit-tested separately (probe refuses before lease on Linux).
18449    #[cfg(feature = "dig2-station-probe")]
18450    #[test]
18451    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18452        let (shared, defaults) = station_knobs_fixture();
18453        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18454        let spec = crate::protocol::SpawnSpec {
18455            target: crate::protocol::SpawnTarget {
18456                node_id: shared.node_id.clone(),
18457                workspace_id: WorkspaceId::new("primary").unwrap(),
18458                worktree_id: None,
18459            },
18460            profile_id: defaults.profile_id.clone(),
18461            expected_profile_revision: defaults.revision.clone(),
18462            overrides: crate::protocol::SpawnOverrides {
18463                browser_profile_id: Some(browser),
18464                ..crate::protocol::SpawnOverrides::default()
18465            },
18466            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18467            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18468                "station-knobs-probe-refuse",
18469            )
18470            .unwrap(),
18471            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18472        };
18473        let resolved = spec.resolve(&defaults).unwrap();
18474        let failure = shared
18475            .resolve_environment_profile(&resolved)
18476            .expect_err("browser_profile with failing probe must refuse");
18477        #[cfg(windows)]
18478        {
18479            assert!(
18480                matches!(
18481                    failure.code,
18482                    NodeFailureCode::BrowserStationUnreachable
18483                        | NodeFailureCode::BrowserStationProbeUnavailable
18484                ),
18485                "unexpected code {:?}",
18486                failure.code
18487            );
18488        }
18489        #[cfg(not(windows))]
18490        {
18491            assert_eq!(
18492                failure.code,
18493                NodeFailureCode::BrowserStationProbeUnavailable
18494            );
18495        }
18496    }
18497
18498    /// Feature on: omitting browser_profile_id still resolves (no probe).
18499    #[cfg(feature = "dig2-station-probe")]
18500    #[test]
18501    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18502        let (shared, defaults) = station_knobs_fixture();
18503        let spec = crate::protocol::SpawnSpec {
18504            target: crate::protocol::SpawnTarget {
18505                node_id: shared.node_id.clone(),
18506                workspace_id: WorkspaceId::new("primary").unwrap(),
18507                worktree_id: None,
18508            },
18509            profile_id: defaults.profile_id.clone(),
18510            expected_profile_revision: defaults.revision.clone(),
18511            overrides: crate::protocol::SpawnOverrides::default(),
18512            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18513            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18514                "station-knobs-probe-omit",
18515            )
18516            .unwrap(),
18517            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18518        };
18519        let resolved = spec.resolve(&defaults).unwrap();
18520        let receipt = shared
18521            .resolve_environment_profile(&resolved)
18522            .expect("omit browser_profile must not probe")
18523            .expect("env profile present");
18524        assert_eq!(receipt.browser_profile_id, None);
18525    }
18526
18527    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18528    /// refuses a second holder for the same opaque profile id; release on
18529    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18530    #[cfg(feature = "dig2-station-probe")]
18531    #[test]
18532    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18533        let (shared, _defaults) = station_knobs_fixture();
18534        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18535        let workspace_id = WorkspaceId::new("primary").unwrap();
18536        let address_a = SessionAddress {
18537            workspace_id: workspace_id.clone(),
18538            session: SessionKey {
18539                instance_id: AgentInstanceId(101),
18540                generation: SessionGeneration(1),
18541            },
18542        };
18543        let address_b = SessionAddress {
18544            workspace_id: workspace_id.clone(),
18545            session: SessionKey {
18546                instance_id: AgentInstanceId(102),
18547                generation: SessionGeneration(1),
18548            },
18549        };
18550        let guard_a = crate::dig2_station_lease::acquire_lease(
18551            &shared.browser_station_leases,
18552            profile.clone(),
18553            address_a.session.instance_id,
18554            1,
18555        )
18556        .expect("first lease");
18557        let busy = crate::dig2_station_lease::acquire_lease(
18558            &shared.browser_station_leases,
18559            profile.clone(),
18560            address_b.session.instance_id,
18561            2,
18562        )
18563        .expect_err("second holder must refuse");
18564        assert!(matches!(
18565            busy,
18566            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18567        ));
18568        // Simulate a bound session so remove_binding clears the lease.
18569        shared.bind_session_with_policy(
18570            &address_a,
18571            ProviderRuntimePolicy::raw_pty(),
18572            None,
18573        );
18574        guard_a.retain();
18575        assert!(shared.remove_binding(&address_a).is_some());
18576        crate::dig2_station_lease::acquire_lease(
18577            &shared.browser_station_leases,
18578            profile,
18579            address_b.session.instance_id,
18580            3,
18581        )
18582        .expect("reacquire after clear")
18583        .retain();
18584        assert!(
18585            crate::dig2_station_lease::release_lease_for_holder(
18586                &shared.browser_station_leases,
18587                address_b.session.instance_id,
18588            )
18589        );
18590    }
18591
18592    /// Feature-off: no browser_station_leases field; stub echo documented in
18593    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18594    #[cfg(not(feature = "dig2-station-probe"))]
18595    #[test]
18596    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18597        let (shared, defaults) = station_knobs_fixture();
18598        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18599        let spec = crate::protocol::SpawnSpec {
18600            target: crate::protocol::SpawnTarget {
18601                node_id: shared.node_id.clone(),
18602                workspace_id: WorkspaceId::new("primary").unwrap(),
18603                worktree_id: None,
18604            },
18605            profile_id: defaults.profile_id.clone(),
18606            expected_profile_revision: defaults.revision.clone(),
18607            overrides: crate::protocol::SpawnOverrides {
18608                browser_profile_id: Some(browser.clone()),
18609                ..crate::protocol::SpawnOverrides::default()
18610            },
18611            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18612            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18613                .unwrap(),
18614            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18615        };
18616        let resolved = spec.resolve(&defaults).unwrap();
18617        let receipt = shared
18618            .resolve_environment_profile(&resolved)
18619            .expect("stub resolve")
18620            .expect("env profile");
18621        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18622    }
18623
18624
18625    /// Startup file loader registers ids into the empty-default catalog.
18626    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18627    #[test]
18628    fn network_allowlist_catalog_loads_from_temp_file() {
18629        let unique = std::time::SystemTime::now()
18630            .duration_since(std::time::UNIX_EPOCH)
18631            .unwrap()
18632            .as_nanos();
18633        let path = std::env::temp_dir().join(format!(
18634            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18635            std::process::id(),
18636        ));
18637        std::fs::write(
18638            &path,
18639            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18640        )
18641        .unwrap();
18642        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18643            .expect("temp catalog file must load");
18644        let _ = std::fs::remove_file(&path);
18645        assert_eq!(loaded.len(), 2);
18646        let (shared, defaults) = station_knobs_fixture();
18647        {
18648            let mut catalog = shared
18649                .network_allowlist_catalog
18650                .write()
18651                .unwrap_or_else(|poisoned| poisoned.into_inner());
18652            *catalog = loaded;
18653        }
18654        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18655        let spec = crate::protocol::SpawnSpec {
18656            target: crate::protocol::SpawnTarget {
18657                node_id: shared.node_id.clone(),
18658                workspace_id: WorkspaceId::new("primary").unwrap(),
18659                worktree_id: None,
18660            },
18661            profile_id: defaults.profile_id.clone(),
18662            expected_profile_revision: defaults.revision.clone(),
18663            overrides: crate::protocol::SpawnOverrides {
18664                network_allowlist: Some(allowlist.clone()),
18665                ..crate::protocol::SpawnOverrides::default()
18666            },
18667            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18668            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18669                "station-knobs-catalog-file",
18670            )
18671            .unwrap(),
18672            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18673        };
18674        let resolved = spec.resolve(&defaults).unwrap();
18675        let receipt = shared
18676            .resolve_environment_profile(&resolved)
18677            .expect("registered via file load")
18678            .expect("env profile present");
18679        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18680    }
18681
18682    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18683    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18684    #[test]
18685    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18686        let (shared, _defaults) = station_knobs_fixture();
18687        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18688        shared
18689            .network_allowlist_catalog
18690            .write()
18691            .unwrap_or_else(|poisoned| poisoned.into_inner())
18692            .insert_id(allowlist.clone())
18693            .unwrap();
18694        let snapshot = shared.snapshot();
18695        let inventory = snapshot
18696            .launch_inventory
18697            .expect("launch inventory present");
18698        let ids = inventory
18699            .network_allowlists
18700            .expect("non-empty catalog surfaces ids");
18701        assert_eq!(ids, vec![allowlist]);
18702    }
18703
18704    /// Track B: registered id with provider_native codex_network_access refuses
18705    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18706    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18707    #[test]
18708    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18709        let (shared, defaults) = station_knobs_fixture();
18710        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18711        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18712            id: allowlist.clone(),
18713            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18714                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18715                peer: "127.0.0.1:443".to_owned(),
18716            }],
18717            provider_native: Some(
18718                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18719                    codex_network_access: Some(true),
18720                },
18721            ),
18722        };
18723        shared
18724            .network_allowlist_catalog
18725            .write()
18726            .unwrap_or_else(|poisoned| poisoned.into_inner())
18727            .insert(entry)
18728            .unwrap();
18729        let spec = crate::protocol::SpawnSpec {
18730            target: crate::protocol::SpawnTarget {
18731                node_id: shared.node_id.clone(),
18732                workspace_id: WorkspaceId::new("primary").unwrap(),
18733                worktree_id: None,
18734            },
18735            profile_id: defaults.profile_id.clone(),
18736            expected_profile_revision: defaults.revision.clone(),
18737            overrides: crate::protocol::SpawnOverrides {
18738                network_allowlist: Some(allowlist),
18739                ..crate::protocol::SpawnOverrides::default()
18740            },
18741            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18742            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18743                "station-knobs-unsupported-mapping",
18744            )
18745            .unwrap(),
18746            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18747        };
18748        let resolved = spec.resolve(&defaults).unwrap();
18749        assert_eq!(resolved.provider.as_str(), "claude");
18750        let failure = shared
18751            .resolve_environment_profile(&resolved)
18752            .expect_err("claude + codex_network_access must refuse");
18753        assert_eq!(
18754            failure.code,
18755            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18756        );
18757    }
18758
18759    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18760    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18761    /// Never secrets on C2.
18762    #[test]
18763    fn resolve_codex_network_access_overlay_moderate_only() {
18764        let (shared, defaults) = station_knobs_fixture();
18765        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18766        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18767            id: allowlist.clone(),
18768            permits: Vec::new(),
18769            provider_native: Some(
18770                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18771                    codex_network_access: Some(true),
18772                },
18773            ),
18774        };
18775        shared
18776            .network_allowlist_catalog
18777            .write()
18778            .unwrap_or_else(|poisoned| poisoned.into_inner())
18779            .insert(entry)
18780            .unwrap();
18781
18782        let overlay = shared
18783            .resolve_codex_network_access_overlay_args(
18784                &agent("codex"),
18785                SessionMode::Pty,
18786                ApprovalLevel::Moderate,
18787                Some(&allowlist),
18788            )
18789            .expect("Moderate Codex maps network_access");
18790        assert_eq!(
18791            overlay,
18792            vec![
18793                std::ffi::OsString::from("-c"),
18794                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18795            ]
18796        );
18797
18798        let acp = shared
18799            .resolve_codex_network_access_overlay_args(
18800                &agent("codex"),
18801                SessionMode::Acp,
18802                ApprovalLevel::Moderate,
18803                Some(&allowlist),
18804            )
18805            .unwrap();
18806        assert_eq!(acp, overlay);
18807
18808        let inline = shared
18809            .resolve_codex_network_access_overlay_args(
18810                &agent("codex"),
18811                SessionMode::Inline,
18812                ApprovalLevel::Moderate,
18813                Some(&allowlist),
18814            )
18815            .unwrap_err();
18816        assert_eq!(
18817            inline.code,
18818            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18819        );
18820
18821        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18822            let spec = crate::protocol::SpawnSpec {
18823                target: crate::protocol::SpawnTarget {
18824                    node_id: shared.node_id.clone(),
18825                    workspace_id: WorkspaceId::new("primary").unwrap(),
18826                    worktree_id: None,
18827                },
18828                profile_id: defaults.profile_id.clone(),
18829                expected_profile_revision: defaults.revision.clone(),
18830                overrides: crate::protocol::SpawnOverrides {
18831                    provider: crate::protocol::SpawnOverride::Set {
18832                        value: agent("codex"),
18833                    },
18834                    approval_level: Some(level),
18835                    network_allowlist: Some(allowlist.clone()),
18836                    ..crate::protocol::SpawnOverrides::default()
18837                },
18838                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18839                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18840                    "codex-net-refuse-{}",
18841                    match level {
18842                        ApprovalLevel::ReadOnly => "ro",
18843                        ApprovalLevel::FullAuto => "fa",
18844                        _ => "other",
18845                    }
18846                ))
18847                .unwrap(),
18848                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18849            };
18850            let resolved = spec.resolve(&defaults).unwrap();
18851            let failure = shared
18852                .resolve_environment_profile(&resolved)
18853                .expect_err("RO/FullAuto must refuse");
18854            assert_eq!(
18855                failure.code,
18856                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18857            );
18858        }
18859    }
18860
18861    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18862    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18863    /// Never secrets on C2; does not invent provider_native Claude keys.
18864    #[test]
18865    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18866        let (shared, _defaults) = station_knobs_fixture();
18867        let allowlist =
18868            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18869        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18870            id: allowlist.clone(),
18871            permits: vec![
18872                crate::network_allowlist_catalog::NetworkPermitSketch {
18873                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18874                    peer: "github.com:443".to_owned(),
18875                },
18876                crate::network_allowlist_catalog::NetworkPermitSketch {
18877                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18878                    peer: "*.npmjs.org".to_owned(),
18879                },
18880            ],
18881            provider_native: None,
18882        };
18883        shared
18884            .network_allowlist_catalog
18885            .write()
18886            .unwrap_or_else(|poisoned| poisoned.into_inner())
18887            .insert(entry)
18888            .unwrap();
18889
18890        let unique = std::time::SystemTime::now()
18891            .duration_since(std::time::UNIX_EPOCH)
18892            .unwrap()
18893            .as_nanos();
18894        let provider_home = std::env::temp_dir().join(format!(
18895            "gate4agent-claude-provider-home-{}-{unique}",
18896            std::process::id(),
18897        ));
18898        std::fs::create_dir_all(&provider_home).unwrap();
18899
18900        let overlay = shared
18901            .resolve_network_allowlist_overlay_args(
18902                &agent("claude"),
18903                SessionMode::Pty,
18904                ApprovalLevel::Moderate,
18905                Some(&allowlist),
18906                Some(provider_home.as_path()),
18907            )
18908            .expect("Claude PTY settings overlay on unix");
18909        let settings_path = provider_home.join(
18910            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18911        );
18912        assert_eq!(
18913            overlay,
18914            vec![
18915                std::ffi::OsString::from("--settings"),
18916                std::ffi::OsString::from(settings_path.as_os_str()),
18917            ]
18918        );
18919        let body = std::fs::read_to_string(&settings_path).unwrap();
18920        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18921        assert_eq!(value["sandbox"]["enabled"], true);
18922        assert_eq!(
18923            value["sandbox"]["network"]["allowedDomains"],
18924            serde_json::json!(["github.com", "*.npmjs.org"])
18925        );
18926        assert!(settings_path.starts_with(&provider_home));
18927
18928        let acp = shared
18929            .resolve_network_allowlist_overlay_args(
18930                &agent("claude"),
18931                SessionMode::Acp,
18932                ApprovalLevel::Moderate,
18933                Some(&allowlist),
18934                Some(provider_home.as_path()),
18935            )
18936            .expect_err("Claude ACP must refuse settings overlay");
18937        assert_eq!(
18938            acp.code,
18939            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18940        );
18941
18942        let no_home = shared
18943            .resolve_network_allowlist_overlay_args(
18944                &agent("claude"),
18945                SessionMode::Pty,
18946                ApprovalLevel::Moderate,
18947                Some(&allowlist),
18948                None,
18949            )
18950            .expect_err("Claude overlay requires ProviderHome");
18951        assert_eq!(
18952            no_home.code,
18953            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18954        );
18955
18956        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18957        shared
18958            .network_allowlist_catalog
18959            .write()
18960            .unwrap_or_else(|poisoned| poisoned.into_inner())
18961            .insert_id(bare.clone())
18962            .unwrap();
18963        let empty = shared
18964            .resolve_network_allowlist_overlay_args(
18965                &agent("claude"),
18966                SessionMode::Pty,
18967                ApprovalLevel::Moderate,
18968                Some(&bare),
18969                Some(provider_home.as_path()),
18970            )
18971            .unwrap();
18972        assert!(empty.is_empty());
18973
18974        let _ = std::fs::remove_dir_all(&provider_home);
18975    }
18976
18977    /// Kimi / Grok with permits stay without invented argv overlays.
18978    #[test]
18979    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18980        let (shared, _defaults) = station_knobs_fixture();
18981        let allowlist =
18982            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18983        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18984            id: allowlist.clone(),
18985            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18986                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18987                peer: "example.com:443".to_owned(),
18988            }],
18989            provider_native: None,
18990        };
18991        shared
18992            .network_allowlist_catalog
18993            .write()
18994            .unwrap_or_else(|poisoned| poisoned.into_inner())
18995            .insert(entry)
18996            .unwrap();
18997        for provider in ["kimi", "grok"] {
18998            let overlay = shared
18999                .resolve_network_allowlist_overlay_args(
19000                    &agent(provider),
19001                    SessionMode::Pty,
19002                    ApprovalLevel::Moderate,
19003                    Some(&allowlist),
19004                    None,
19005                )
19006                .unwrap();
19007            assert!(
19008                overlay.is_empty(),
19009                "{provider} must not invent network argv from permits"
19010            );
19011        }
19012    }
19013
19014    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19015    #[test]
19016    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19017        let unique = std::time::SystemTime::now()
19018            .duration_since(std::time::UNIX_EPOCH)
19019            .unwrap()
19020            .as_nanos();
19021        let path = std::env::temp_dir().join(format!(
19022            "gate4agent-node-allowlist-json-{}-{unique}.json",
19023            std::process::id(),
19024        ));
19025        std::fs::write(
19026            &path,
19027            r#"{
19028              "schema_version": 2,
19029              "entries": [
19030                {
19031                  "id": "egress-default",
19032                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19033                },
19034                { "id": "lab-egress" }
19035              ]
19036            }"#,
19037        )
19038        .unwrap();
19039        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19040            .expect("json v2 catalog must load");
19041        let _ = std::fs::remove_file(&path);
19042        assert_eq!(loaded.len(), 2);
19043        let entry = loaded
19044            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19045            .unwrap();
19046        assert_eq!(entry.permits.len(), 1);
19047        let (shared, defaults) = station_knobs_fixture();
19048        {
19049            let mut catalog = shared
19050                .network_allowlist_catalog
19051                .write()
19052                .unwrap_or_else(|poisoned| poisoned.into_inner());
19053            *catalog = loaded;
19054        }
19055        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19056        let spec = crate::protocol::SpawnSpec {
19057            target: crate::protocol::SpawnTarget {
19058                node_id: shared.node_id.clone(),
19059                workspace_id: WorkspaceId::new("primary").unwrap(),
19060                worktree_id: None,
19061            },
19062            profile_id: defaults.profile_id.clone(),
19063            expected_profile_revision: defaults.revision.clone(),
19064            overrides: crate::protocol::SpawnOverrides {
19065                network_allowlist: Some(allowlist.clone()),
19066                ..crate::protocol::SpawnOverrides::default()
19067            },
19068            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19069            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19070                "station-knobs-catalog-json",
19071            )
19072            .unwrap(),
19073            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19074        };
19075        let resolved = spec.resolve(&defaults).unwrap();
19076        let receipt = shared
19077            .resolve_environment_profile(&resolved)
19078            .expect("permits-only entry ok for claude")
19079            .expect("env profile present");
19080        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19081        let inventory = shared
19082            .snapshot()
19083            .launch_inventory
19084            .expect("launch inventory present");
19085        let ids = inventory.network_allowlists.expect("ids surface");
19086        assert_eq!(
19087            ids,
19088            vec![
19089                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19090                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19091            ]
19092        );
19093    }
19094
19095
19096}
19097
19098
19099#[cfg(test)]
19100mod workspace_environment_collision_tests {
19101    use super::*;
19102    use crate::session_environment::{
19103        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19104    };
19105    use gate4agent_catalog::EnvMutation;
19106    use gate4agent_runtime_native::{
19107        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19108        NativeLaunchProfileId,
19109    };
19110    use std::ffi::OsString;
19111    use std::sync::Arc;
19112
19113    struct EmptyEnvironmentResolver;
19114
19115    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19116        fn resolve_child_environment(
19117            &self,
19118        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19119            Ok(vec![EnvMutation {
19120                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19121                value: None,
19122            }])
19123        }
19124    }
19125
19126    fn agent(value: &str) -> AgentId {
19127        AgentId::new(value).unwrap()
19128    }
19129
19130    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19131    fn claude_config_dir_collision_fixture() -> (
19132        NodeShared,
19133        ResolvedEnvironmentProfileReceipt,
19134        ResolvedEnvironmentProfileReceipt,
19135    ) {
19136        let catalog = active_registry().unwrap();
19137        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19138        let control = runtime.native_launch_profile_control();
19139        let workspace = WorkspaceConfig::new(
19140            WorkspaceId::new("primary").unwrap(),
19141            std::env::current_dir().unwrap(),
19142        )
19143        .unwrap();
19144        let shared = NodeShared::new_with_incarnation(
19145            handle,
19146            "fixture-token".to_owned(),
19147            NodeId::new("node-env-collision-test").unwrap(),
19148            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19149            vec![workspace],
19150            vec![agent("claude")],
19151            ProviderRuntimeStatuses::default(),
19152            None,
19153            Vec::new(),
19154            Vec::new(),
19155            SpawnProfileRegistry::default(),
19156            Some(control),
19157            None,
19158            None,
19159            None,
19160            Vec::new(),
19161            Vec::new(),
19162            Vec::new(),
19163            Vec::new(),
19164            None,
19165            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19166        );
19167
19168        let mut receipts = Vec::new();
19169        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19170            let profile_id =
19171                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19172            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19173                "claude-config-dir-{suffix}-r1"
19174            ))
19175            .unwrap();
19176            let materialization = NodeSessionMaterializationProfile::new(
19177                Vec::new(),
19178                vec![NodeSessionPathBinding::new(
19179                    "CLAUDE_CONFIG_DIR",
19180                    NodeSessionPathClass::ProviderHome,
19181                )
19182                .unwrap()],
19183                Vec::new(),
19184            )
19185            .unwrap();
19186            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19187                profile_id.clone(),
19188                profile_revision.clone(),
19189                agent("claude"),
19190                [NativeLaunchProfile::new(
19191                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19192                        .unwrap(),
19193                    agent("claude"),
19194                    TransportKind::Pty,
19195                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19196                    Arc::new(EmptyEnvironmentResolver),
19197                )
19198                .unwrap()],
19199                Some(materialization),
19200            )
19201            .unwrap();
19202            let (binding, native_profiles, materialization) = node_profile.into_parts();
19203            for native_profile in native_profiles {
19204                runtime.upsert_native_launch_profile(native_profile).unwrap();
19205            }
19206            shared
19207                .environment_profiles
19208                .write()
19209                .unwrap_or_else(|poisoned| poisoned.into_inner())
19210                .insert(binding.id.clone(), binding);
19211            shared
19212                .environment_materialization_profiles
19213                .write()
19214                .unwrap_or_else(|poisoned| poisoned.into_inner())
19215                .insert(profile_id.clone(), materialization.unwrap());
19216            receipts.push(ResolvedEnvironmentProfileReceipt {
19217                profile_id,
19218                profile_revision,
19219                network_allowlist: None,
19220                browser_profile_id: None,
19221            });
19222        }
19223        let profile_b = receipts.pop().unwrap();
19224        let profile_a = receipts.pop().unwrap();
19225        (shared, profile_a, profile_b)
19226    }
19227
19228    #[test]
19229    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19230        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19231        let primary = WorkspaceId::new("primary").unwrap();
19232        let worktree = WorkspaceId::new("worktree-b").unwrap();
19233        let address = SessionAddress {
19234            workspace_id: primary.clone(),
19235            session: SessionKey {
19236                instance_id: AgentInstanceId(41),
19237                generation: SessionGeneration(1),
19238            },
19239        };
19240        shared.bind_session_with_policy(
19241            &address,
19242            ProviderRuntimePolicy::raw_pty(),
19243            Some(profile_a.clone()),
19244        );
19245
19246        let refused = shared
19247            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19248            .unwrap_err();
19249        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19250
19251        // Some vs None on the same cwd is also a collision.
19252        let refused_none = shared
19253            .ensure_workspace_environment_profile_compatible(&primary, None)
19254            .unwrap_err();
19255        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19256
19257        // Same profile on the same workspace remains allowed.
19258        shared
19259            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19260            .unwrap();
19261
19262        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19263        shared
19264            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19265            .unwrap();
19266    }
19267
19268    #[tokio::test]
19269    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19270        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19271        let primary = WorkspaceId::new("primary").unwrap();
19272        let address = SessionAddress {
19273            workspace_id: primary.clone(),
19274            session: SessionKey {
19275                instance_id: AgentInstanceId(41),
19276                generation: SessionGeneration(1),
19277            },
19278        };
19279        shared.bind_session_with_policy(
19280            &address,
19281            ProviderRuntimePolicy::raw_pty(),
19282            Some(profile_a),
19283        );
19284
19285        let failure = shared
19286            .spawn_session_with_deadline(
19287                primary,
19288                agent("claude"),
19289                SessionMode::Pty,
19290                gate4agent_types::TerminalSize {
19291                    rows: 24,
19292                    columns: 80,
19293                },
19294                None,
19295                Some(profile_b),
19296                None,
19297                None,
19298                None,
19299                None,
19300                SpawnRecordPolicy::ProviderIdentityOnly,
19301                Some(Instant::now() + Duration::from_secs(30)),
19302                &[],
19303                None,
19304                ApprovalLevel::default(),
19305                None,
19306                None,
19307            )
19308            .await
19309            .unwrap_err();
19310        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19311        // Guard runs before instance id mint / bind.
19312        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19313        assert!(shared.handle.snapshot().sessions.is_empty());
19314    }
19315}
19316
19317#[cfg(test)]
19318mod spawn_characterization_tests {
19319    use super::*;
19320    use crate::session_environment::{
19321        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19322        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19323        NodeSessionPathClass,
19324    };
19325    use gate4agent_catalog::EnvMutation;
19326    use gate4agent_runtime_native::{
19327        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19328        NativeLaunchProfile, NativeLaunchProfileId,
19329    };
19330    use std::ffi::OsString;
19331    use std::sync::Arc;
19332
19333    struct EmptyEnvironmentResolver;
19334
19335    struct FixtureSecretResolver {
19336        deny: Arc<AtomicBool>,
19337    }
19338
19339    impl NodeSecretResolver for FixtureSecretResolver {
19340        fn resolve(
19341            &self,
19342            _reference: &NodeSecretReference,
19343        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19344            if self.deny.load(Ordering::Acquire) {
19345                Err(NodeSecretResolveError::Denied)
19346            } else {
19347                NodeSecretValue::text("fixture-secret")
19348                    .map_err(|_| NodeSecretResolveError::Unavailable)
19349            }
19350        }
19351    }
19352
19353    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19354        fn resolve_child_environment(
19355            &self,
19356        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19357            Ok(vec![EnvMutation {
19358                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19359                value: None,
19360            }])
19361        }
19362    }
19363
19364    fn agent(value: &str) -> AgentId {
19365        AgentId::new(value).unwrap()
19366    }
19367
19368    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19369        let unique = std::time::SystemTime::now()
19370            .duration_since(std::time::UNIX_EPOCH)
19371            .unwrap()
19372            .as_nanos();
19373        std::env::temp_dir().join(format!(
19374            "gate4agent-node-{label}-{}-{unique}",
19375            std::process::id(),
19376        ))
19377    }
19378
19379    fn materialization_test_shared(
19380        deny_secret: bool,
19381    ) -> (
19382        NodeShared,
19383        NativeRuntime,
19384        ResolvedEnvironmentProfileReceipt,
19385        PathBuf,
19386        Arc<AtomicBool>,
19387    ) {
19388        let workspace = WorkspaceConfig::new(
19389            WorkspaceId::new("primary").unwrap(),
19390            std::env::current_dir().unwrap(),
19391        )
19392        .unwrap();
19393        materialization_test_shared_with_workspace(deny_secret, workspace)
19394    }
19395
19396    fn materialization_test_shared_with_workspace(
19397        deny_secret: bool,
19398        workspace: WorkspaceConfig,
19399    ) -> (
19400        NodeShared,
19401        NativeRuntime,
19402        ResolvedEnvironmentProfileReceipt,
19403        PathBuf,
19404        Arc<AtomicBool>,
19405    ) {
19406        let root = temporary_workspace_root(if deny_secret {
19407            "materialization-denied"
19408        } else {
19409            "materialization-ready"
19410        });
19411        std::fs::create_dir_all(&root).unwrap();
19412        let materialization_root = root.join("session-environments");
19413        let deny = Arc::new(AtomicBool::new(deny_secret));
19414        let resolver = Arc::new(FixtureSecretResolver {
19415            deny: Arc::clone(&deny),
19416        });
19417        let materializer = SessionEnvironmentMaterializer::new(
19418            materialization_root,
19419            resolver,
19420        )
19421        .unwrap();
19422        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19423        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19424            "materialized-claude-r1",
19425        )
19426        .unwrap();
19427        let materialization = NodeSessionMaterializationProfile::new(
19428            vec![NodeSessionEnvironmentMutation::SetSecret {
19429                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19430                reference: NodeSecretReference::new("fixture-token").unwrap(),
19431            }],
19432            vec![NodeSessionPathBinding::new(
19433                "GATE4AGENT_PROVIDER_HOME",
19434                NodeSessionPathClass::ProviderHome,
19435            )
19436            .unwrap()],
19437            vec![NodeSessionFile::secret(
19438                NodeSessionPathClass::Config,
19439                "auth/token",
19440                NodeSecretReference::new("fixture-token").unwrap(),
19441            )
19442            .unwrap()],
19443        )
19444        .unwrap();
19445        let catalog = active_registry().unwrap();
19446        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19447        let control = runtime.native_launch_profile_control();
19448        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19449            profile_id.clone(),
19450            profile_revision.clone(),
19451            agent("claude"),
19452            [NativeLaunchProfile::new(
19453                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19454                agent("claude"),
19455                TransportKind::Pty,
19456                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19457                Arc::new(EmptyEnvironmentResolver),
19458            )
19459            .unwrap()],
19460            Some(materialization),
19461        )
19462        .unwrap();
19463        let (binding, native_profiles, materialization) = node_profile.into_parts();
19464        for native_profile in native_profiles {
19465            runtime.upsert_native_launch_profile(native_profile).unwrap();
19466        }
19467        let shared = NodeShared::new_with_incarnation(
19468            handle,
19469            "fixture-token".to_owned(),
19470            NodeId::new("node-materialization-test").unwrap(),
19471            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19472            vec![workspace],
19473            vec![agent("claude")],
19474            ProviderRuntimeStatuses::default(),
19475            None,
19476            Vec::new(),
19477            Vec::new(),
19478            SpawnProfileRegistry::default(),
19479            Some(control),
19480            None,
19481            Some(materializer),
19482            None,
19483            Vec::new(),
19484            Vec::new(),
19485            Vec::new(),
19486            Vec::new(),
19487            None,
19488            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19489        );
19490        shared
19491            .environment_profiles
19492            .write()
19493            .unwrap_or_else(|poisoned| poisoned.into_inner())
19494            .insert(binding.id.clone(), binding);
19495        shared
19496            .environment_materialization_profiles
19497            .write()
19498            .unwrap_or_else(|poisoned| poisoned.into_inner())
19499            .insert(profile_id.clone(), materialization.unwrap());
19500        (
19501            shared,
19502            runtime,
19503            ResolvedEnvironmentProfileReceipt {
19504                profile_id,
19505                profile_revision,
19506                network_allowlist: None,
19507                browser_profile_id: None,
19508            },
19509            root,
19510            deny,
19511        )
19512    }
19513
19514
19515    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19516
19517    async fn characterization_spawn(
19518        shared: &NodeShared,
19519        mode: SessionMode,
19520        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19521        required: &[ProviderRuntimeCapability],
19522        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19523    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19524        shared
19525            .spawn_session_with_deadline(
19526                WorkspaceId::new("primary").unwrap(),
19527                agent("claude"),
19528                mode,
19529                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19530                None,
19531                environment_profile,
19532                None,
19533                None,
19534                None,
19535                Some(ProviderRuntimePolicy::raw_pty()),
19536                SpawnRecordPolicy::ProviderIdentityOnly,
19537                Some(Instant::now() + Duration::from_secs(30)),
19538                required,
19539                harness_mcp,
19540                ApprovalLevel::default(),
19541                None,
19542                None,
19543            )
19544            .await
19545    }
19546
19547    fn assert_no_session_state(shared: &NodeShared) {
19548        assert!(shared.handle.snapshot().sessions.is_empty());
19549        assert!(shared
19550            .session_bindings
19551            .lock()
19552            .unwrap_or_else(|poisoned| poisoned.into_inner())
19553            .is_empty());
19554        assert!(shared
19555            .materializations
19556            .lock()
19557            .unwrap_or_else(|poisoned| poisoned.into_inner())
19558            .is_empty());
19559    }
19560
19561    #[tokio::test]
19562    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19563        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19564        let error = characterization_spawn(
19565            &shared,
19566            SessionMode::Pty,
19567            None,
19568            &[ProviderRuntimeCapability::SemanticReadiness],
19569            None,
19570        )
19571        .await
19572        .unwrap_err();
19573        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19574        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19575        assert_no_session_state(&shared);
19576        drop(shared);
19577        drop(runtime);
19578        std::fs::remove_dir_all(root).unwrap();
19579    }
19580
19581    #[tokio::test]
19582    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19583        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19584        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19585            .await
19586            .unwrap_err();
19587        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19588        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19589        assert_no_session_state(&shared);
19590        drop(shared);
19591        drop(runtime);
19592        std::fs::remove_dir_all(root).unwrap();
19593    }
19594
19595    #[test]
19596    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19597        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19598        let address = SessionAddress {
19599            workspace_id: WorkspaceId::new("primary").unwrap(),
19600            session: SessionKey {
19601                instance_id: AgentInstanceId(77),
19602                generation: SessionGeneration(1),
19603            },
19604        };
19605        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19606        let removed = shared.remove_binding(&address).expect("binding removed");
19607        assert!(removed.environment_profile.is_some());
19608        assert!(shared.remove_binding(&address).is_none());
19609        assert_no_session_state(&shared);
19610        drop(shared);
19611        drop(runtime);
19612        std::fs::remove_dir_all(root).unwrap();
19613    }
19614
19615    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19616        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19617        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19618        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19619            .expect("review current test executable as fixture helper");
19620        let registry = HarnessMcpProxyRegistry::new(helper);
19621        let spec = crate::protocol::SpawnSpec {
19622            target: crate::protocol::SpawnTarget {
19623                node_id: NodeId::new("node-characterization").unwrap(),
19624                workspace_id: WorkspaceId::new("primary").unwrap(),
19625                worktree_id: None,
19626            },
19627            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19628            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19629            overrides: crate::protocol::SpawnOverrides::default(),
19630            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19631            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19632            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19633        };
19634        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19635        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19636        let launch = HarnessMcpLaunchV1 {
19637            server_name: "fixture-harness".to_owned(),
19638            args: vec!["--session-proxy".to_owned()],
19639            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19640            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19641            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19642            trace: None,
19643            scrub_env: Vec::new(),
19644        };
19645        let expires = unix_time_ms().saturating_add(30_000);
19646        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19647        registry
19648            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19649            .unwrap()
19650    }
19651
19652    #[tokio::test]
19653    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19654        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19655        let prepared = characterization_prepared_harness("claude").await;
19656        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19657            .await
19658            .unwrap_err();
19659        // Inline is stopped by runtime admission before the harness check runs.
19660        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19661        let mismatched = characterization_prepared_harness("codex").await;
19662        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19663            .await
19664            .unwrap_err();
19665        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19666        assert_no_session_state(&shared);
19667        drop(shared);
19668        drop(runtime);
19669        std::fs::remove_dir_all(root).unwrap();
19670    }
19671
19672    #[tokio::test]
19673    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19674        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19675        shared.native_launch_profile_control = None;
19676        let prepared = characterization_prepared_harness("claude").await;
19677        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19678            .await
19679            .unwrap_err();
19680        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19681        assert_no_session_state(&shared);
19682        drop(shared);
19683        drop(runtime);
19684        std::fs::remove_dir_all(root).unwrap();
19685    }
19686}
19687
19688#[cfg(all(test, windows))]
19689mod tests {
19690    use super::*;
19691    use gate4agent_types::ProviderActivity;
19692    use crate::session_environment::{
19693        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19694        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19695        NodeSessionPathClass,
19696    };
19697    use gate4agent_catalog::EnvMutation;
19698    use gate4agent_runtime_native::{
19699        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19700        NativeLaunchProfile, NativeLaunchProfileId,
19701    };
19702    use std::ffi::OsString;
19703    use std::sync::Arc;
19704
19705    struct EmptyEnvironmentResolver;
19706
19707    struct FixtureSecretResolver {
19708        deny: Arc<AtomicBool>,
19709    }
19710
19711    impl NodeSecretResolver for FixtureSecretResolver {
19712        fn resolve(
19713            &self,
19714            _reference: &NodeSecretReference,
19715        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19716            if self.deny.load(Ordering::Acquire) {
19717                Err(NodeSecretResolveError::Denied)
19718            } else {
19719                NodeSecretValue::text("fixture-secret")
19720                    .map_err(|_| NodeSecretResolveError::Unavailable)
19721            }
19722        }
19723    }
19724
19725    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19726        fn resolve_child_environment(
19727            &self,
19728        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19729            Ok(vec![EnvMutation {
19730                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19731                value: None,
19732            }])
19733        }
19734    }
19735
19736    fn agent(value: &str) -> AgentId {
19737        AgentId::new(value).unwrap()
19738    }
19739
19740    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19741        let catalog = active_registry().unwrap();
19742        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19743        let workspace = WorkspaceConfig::new(
19744            WorkspaceId::new("primary").unwrap(),
19745            std::env::current_dir().unwrap(),
19746        )
19747        .unwrap()
19748        .with_worktree_service_mode(mode);
19749        NodeShared::new(
19750            handle,
19751            "fixture-token".to_owned(),
19752            NodeId::new("node-terminal-test").unwrap(),
19753            vec![workspace],
19754            vec![agent("claude")],
19755        )
19756    }
19757
19758    fn terminal_test_shared() -> NodeShared {
19759        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19760    }
19761
19762    #[cfg(feature = "fixture")]
19763    #[test]
19764    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19765        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19766        assert_eq!(probe.latest_code(), None);
19767
19768        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19769        assert_eq!(
19770            probe.latest_code(),
19771            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19772        );
19773
19774        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19775        assert_eq!(
19776            probe.latest_code(),
19777            Some(NodeFailureCode::SpawnDeadlineExceeded),
19778        );
19779
19780        probe.record(None);
19781        assert_eq!(probe.latest_code(), None);
19782    }
19783
19784    #[test]
19785    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19786        let source = temporary_workspace_root("launch-inventory-source");
19787        let allocation = temporary_workspace_root("launch-inventory-allocation");
19788        std::fs::create_dir_all(&source).unwrap();
19789        std::fs::create_dir_all(&allocation).unwrap();
19790        let workspace_id = WorkspaceId::new("primary").unwrap();
19791        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19792            .unwrap()
19793            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19794        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19795            workspace = workspace
19796                .with_managed_worktree_profile(
19797                    ManagedWorktreeProfile::new(
19798                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19799                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19800                        &allocation,
19801                        "gate4agent",
19802                        "HEAD",
19803                        ManagedWorktreeRetention::RemoveWhenReleased,
19804                    )
19805                    .unwrap(),
19806                )
19807                .unwrap();
19808        }
19809        let overflow = workspace.clone().with_managed_worktree_profile(
19810            ManagedWorktreeProfile::new(
19811                WorktreeProfileId::new("overflow").unwrap(),
19812                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19813                &allocation,
19814                "gate4agent",
19815                "HEAD",
19816                ManagedWorktreeRetention::Retain,
19817            )
19818            .unwrap(),
19819        );
19820        assert!(matches!(
19821            overflow,
19822            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19823                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19824                ..
19825            }),
19826        ));
19827
19828        let catalog = active_registry().unwrap();
19829        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19830        let shared = NodeShared::new(
19831            handle,
19832            "fixture-token".to_owned(),
19833            NodeId::new("node-launch-inventory").unwrap(),
19834            vec![workspace],
19835            vec![agent("claude")],
19836        );
19837        let snapshot = shared.snapshot();
19838        let launch = snapshot.launch_inventory.as_ref().unwrap();
19839        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19840        assert!(launch.bundles.as_ref().unwrap().is_empty());
19841        let profiles = snapshot.workspaces[0]
19842            .managed_worktree_profiles
19843            .as_ref()
19844            .unwrap();
19845        assert_eq!(
19846            snapshot.workspaces[0].worktree_service_mode,
19847            Some(WorktreeServiceMode::Managed),
19848        );
19849        assert_eq!(
19850            profiles.profiles.len(),
19851            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19852        );
19853        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19854        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19855            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19856        }
19857        let legacy = snapshot_for_wire(
19858            &shared,
19859            true,
19860            true,
19861            false,
19862            false,
19863            true,
19864            false,
19865            true,
19866            false,
19867            true,
19868        );
19869        assert!(legacy.launch_inventory.is_none());
19870        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19871
19872        drop(shared);
19873        std::fs::remove_dir_all(source).unwrap();
19874        std::fs::remove_dir_all(allocation).unwrap();
19875    }
19876
19877    #[test]
19878    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19879        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19880            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19881        }));
19882        let source = gate4agent_types::ProviderSource {
19883            family: AdapterFamily::PtySemantic,
19884            binding: AdapterBinding::new(
19885                gate4agent_types::AdapterId::new("claude").unwrap(),
19886                "fixture/v1",
19887                gate4agent_types::AdapterVerification::SyntheticFixture,
19888            )
19889            .unwrap(),
19890        };
19891        let provider = ProviderSnapshot {
19892            sequence: 17,
19893            session: Some(ProviderSessionIdentity {
19894                key: ProviderSessionKey::SessionId,
19895                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19896                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19897            }),
19898            model: Some("PRIVATE_MODEL".to_owned()),
19899            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19900            completed_turns: 3,
19901            usage: gate4agent_types::TokenUsage {
19902                input_tokens: 10,
19903                output_tokens: 20,
19904                cache_read_tokens: 30,
19905                cache_write_tokens: 40,
19906                reasoning_tokens: 50,
19907                context_window: Some(200_000),
19908            },
19909            lead_activity: ProviderActivity::WaitingForInput,
19910            activity: ProviderActivity::WaitingForInput,
19911            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19912            active_tools: (0..9)
19913                .map(|index| gate4agent_types::ActiveProviderTool {
19914                    id: format!("private-tool-id-{index}"),
19915                    name: if index == 0 {
19916                        format!("  Read\u{0000}{}", "x".repeat(80))
19917                    } else {
19918                        format!("Tool-{index}")
19919                    },
19920                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19921                })
19922                .collect(),
19923            interactions: vec![gate4agent_types::ProviderInteraction {
19924                id: gate4agent_types::ProviderInteractionId(17),
19925                source: source.clone(),
19926                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19927                interaction_kind: ProviderInteractionKind::Approval,
19928                tool_name: " Approve\u{0007}Tool ".to_owned(),
19929                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19930                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19931                resume_lead_activity: Some(ProviderActivity::Working),
19932                status: ProviderInteractionStatus::Pending,
19933            }],
19934            subagents: vec![gate4agent_types::ProviderSubagent {
19935                source: source.clone(),
19936                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19937                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19938                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19939            }],
19940            sources: vec![gate4agent_types::ProviderSourceCursor {
19941                source,
19942                sequence: 17,
19943                gap_count: 2,
19944                stale: true,
19945            }],
19946            last_event: Some(ProviderEvent::Error {
19947                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19948            }),
19949            gap_count: 2,
19950            stale: true,
19951        };
19952        let address = terminal_address(3);
19953        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19954        assert_eq!(entry.address, address);
19955        assert_eq!(entry.progress.provider_sequence, 17);
19956        assert_eq!(entry.progress.active_tool_count, 9);
19957        assert_eq!(
19958            entry.progress.active_tool_labels.len(),
19959            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19960        );
19961        assert_eq!(entry.progress.subagent_count, 1);
19962        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19963        assert!(entry.progress.truncated);
19964        let encoded = serde_json::to_string(&entry).unwrap();
19965        for forbidden in [
19966            "PRIVATE_PROVIDER_SESSION",
19967            "PRIVATE_PROVIDER_PATH",
19968            "PRIVATE_MODEL",
19969            "PRIVATE_TOOL_INVENTORY",
19970            "PRIVATE_CURRENT_PROMPT",
19971            "PRIVATE_TOOL_INPUT_JSON",
19972            "PRIVATE_PROVIDER_REQUEST",
19973            "PRIVATE_INTERACTION_PROMPT",
19974            "PRIVATE_INTERACTION_AGENT",
19975            "PRIVATE_SUBAGENT_ID",
19976            "PRIVATE_SUBAGENT_TYPE",
19977            "PRIVATE_SUBAGENT_DESCRIPTION",
19978            "PRIVATE_PROVIDER_ERROR",
19979            "input_json",
19980            "current_prompt",
19981            "session_id",
19982        ] {
19983            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19984        }
19985
19986        let snapshot = NodeSnapshot {
19987            node_id: NodeId::new("node-progress-test").unwrap(),
19988            enabled_providers: vec![agent("claude")],
19989            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19990            workspaces: Vec::new(),
19991            session_records: Vec::new(),
19992            managed_worktrees: Vec::new(),
19993            launch_inventory: None,
19994            agent_progress: vec![entry],
19995        };
19996        let mut legacy_reply = ResponseEnvelope {
19997            request_id: 1,
19998            result: Ok(NodeResponse::Snapshot {
19999                event_sequence: 0,
20000                controller: None,
20001                snapshot: snapshot.clone(),
20002            }),
20003        };
20004        project_response_without_agent_progress(&mut legacy_reply);
20005        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20006            panic!("agent progress projection changed response kind");
20007        };
20008        assert!(legacy.agent_progress.is_empty());
20009        assert_eq!(snapshot.agent_progress.len(), 1);
20010    }
20011
20012    fn terminal_address(generation: u64) -> SessionAddress {
20013        SessionAddress {
20014            workspace_id: WorkspaceId::new("primary").unwrap(),
20015            session: SessionKey {
20016                instance_id: AgentInstanceId(41),
20017                generation: SessionGeneration(generation),
20018            },
20019        }
20020    }
20021
20022    fn spawn_spec_fixture() -> SpawnSpec {
20023        SpawnSpec {
20024            target: crate::protocol::SpawnTarget {
20025                node_id: NodeId::new("node-terminal-test").unwrap(),
20026                workspace_id: WorkspaceId::new("primary").unwrap(),
20027                worktree_id: None,
20028            },
20029            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20030            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20031                .unwrap(),
20032            overrides: crate::protocol::SpawnOverrides::default(),
20033            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20034            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20035            required_capabilities: SpawnRequiredCapabilities::default(),
20036        }
20037    }
20038
20039    fn environment_profile_test_shared(
20040    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20041        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20042        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20043            "local-claude-r1",
20044        )
20045        .unwrap();
20046        let spawn_profiles = SpawnProfileRegistry::new([
20047            crate::protocol::SpawnProfileDefaults {
20048                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20049                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20050                provider: agent("claude"),
20051                mode: SessionMode::Pty,
20052                terminal_size: gate4agent_types::TerminalSize {
20053                    rows: 24,
20054                    columns: 80,
20055                },
20056                prompt: None,
20057                bundle_id: None,
20058                context_id: None,
20059                environment_profile_id: Some(profile_id.clone()),
20060            },
20061        ])
20062        .unwrap();
20063        let catalog = active_registry().unwrap();
20064        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20065        let control = runtime.native_launch_profile_control();
20066        let node_profile = NodeEnvironmentProfile::new(
20067            profile_id.clone(),
20068            profile_revision.clone(),
20069            agent("claude"),
20070            [NativeLaunchProfile::new(
20071                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20072                agent("claude"),
20073                TransportKind::Pty,
20074                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20075                Arc::new(EmptyEnvironmentResolver),
20076            )
20077            .unwrap()],
20078        )
20079        .unwrap();
20080        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20081        for native_profile in native_profiles {
20082            runtime.upsert_native_launch_profile(native_profile).unwrap();
20083        }
20084        let workspace = WorkspaceConfig::new(
20085            WorkspaceId::new("primary").unwrap(),
20086            std::env::current_dir().unwrap(),
20087        )
20088        .unwrap();
20089        let shared = NodeShared::new_with_incarnation(
20090            handle,
20091            "fixture-token".to_owned(),
20092            NodeId::new("node-terminal-test").unwrap(),
20093            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20094            vec![workspace],
20095            vec![agent("claude")],
20096            ProviderRuntimeStatuses::default(),
20097            None,
20098            Vec::new(),
20099            Vec::new(),
20100            spawn_profiles,
20101            Some(control),
20102            None,
20103            None,
20104            None,
20105            Vec::new(),
20106            Vec::new(),
20107            Vec::new(),
20108            Vec::new(),
20109            None,
20110            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20111        );
20112        shared
20113            .environment_profiles
20114            .write()
20115            .unwrap_or_else(|poisoned| poisoned.into_inner())
20116            .insert(binding.id.clone(), binding);
20117        (
20118            shared,
20119            runtime,
20120            ResolvedEnvironmentProfileReceipt {
20121                profile_id,
20122                profile_revision,
20123                network_allowlist: None,
20124                browser_profile_id: None,
20125            },
20126        )
20127    }
20128
20129    fn materialization_test_shared(
20130        deny_secret: bool,
20131    ) -> (
20132        NodeShared,
20133        NativeRuntime,
20134        ResolvedEnvironmentProfileReceipt,
20135        PathBuf,
20136        Arc<AtomicBool>,
20137    ) {
20138        let workspace = WorkspaceConfig::new(
20139            WorkspaceId::new("primary").unwrap(),
20140            std::env::current_dir().unwrap(),
20141        )
20142        .unwrap();
20143        materialization_test_shared_with_workspace(deny_secret, workspace)
20144    }
20145
20146    fn materialization_test_shared_with_workspace(
20147        deny_secret: bool,
20148        workspace: WorkspaceConfig,
20149    ) -> (
20150        NodeShared,
20151        NativeRuntime,
20152        ResolvedEnvironmentProfileReceipt,
20153        PathBuf,
20154        Arc<AtomicBool>,
20155    ) {
20156        let root = temporary_workspace_root(if deny_secret {
20157            "materialization-denied"
20158        } else {
20159            "materialization-ready"
20160        });
20161        std::fs::create_dir_all(&root).unwrap();
20162        let materialization_root = root.join("session-environments");
20163        let deny = Arc::new(AtomicBool::new(deny_secret));
20164        let resolver = Arc::new(FixtureSecretResolver {
20165            deny: Arc::clone(&deny),
20166        });
20167        let materializer = SessionEnvironmentMaterializer::new(
20168            materialization_root,
20169            resolver,
20170        )
20171        .unwrap();
20172        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20173        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20174            "materialized-claude-r1",
20175        )
20176        .unwrap();
20177        let materialization = NodeSessionMaterializationProfile::new(
20178            vec![NodeSessionEnvironmentMutation::SetSecret {
20179                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20180                reference: NodeSecretReference::new("fixture-token").unwrap(),
20181            }],
20182            vec![NodeSessionPathBinding::new(
20183                "GATE4AGENT_PROVIDER_HOME",
20184                NodeSessionPathClass::ProviderHome,
20185            )
20186            .unwrap()],
20187            vec![NodeSessionFile::secret(
20188                NodeSessionPathClass::Config,
20189                "auth/token",
20190                NodeSecretReference::new("fixture-token").unwrap(),
20191            )
20192            .unwrap()],
20193        )
20194        .unwrap();
20195        let catalog = active_registry().unwrap();
20196        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20197        let control = runtime.native_launch_profile_control();
20198        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20199            profile_id.clone(),
20200            profile_revision.clone(),
20201            agent("claude"),
20202            [NativeLaunchProfile::new(
20203                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20204                agent("claude"),
20205                TransportKind::Pty,
20206                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20207                Arc::new(EmptyEnvironmentResolver),
20208            )
20209            .unwrap()],
20210            Some(materialization),
20211        )
20212        .unwrap();
20213        let (binding, native_profiles, materialization) = node_profile.into_parts();
20214        for native_profile in native_profiles {
20215            runtime.upsert_native_launch_profile(native_profile).unwrap();
20216        }
20217        let shared = NodeShared::new_with_incarnation(
20218            handle,
20219            "fixture-token".to_owned(),
20220            NodeId::new("node-materialization-test").unwrap(),
20221            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20222            vec![workspace],
20223            vec![agent("claude")],
20224            ProviderRuntimeStatuses::default(),
20225            None,
20226            Vec::new(),
20227            Vec::new(),
20228            SpawnProfileRegistry::default(),
20229            Some(control),
20230            None,
20231            Some(materializer),
20232            None,
20233            Vec::new(),
20234            Vec::new(),
20235            Vec::new(),
20236            Vec::new(),
20237            None,
20238            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20239        );
20240        shared
20241            .environment_profiles
20242            .write()
20243            .unwrap_or_else(|poisoned| poisoned.into_inner())
20244            .insert(binding.id.clone(), binding);
20245        shared
20246            .environment_materialization_profiles
20247            .write()
20248            .unwrap_or_else(|poisoned| poisoned.into_inner())
20249            .insert(profile_id.clone(), materialization.unwrap());
20250        (
20251            shared,
20252            runtime,
20253            ResolvedEnvironmentProfileReceipt {
20254                profile_id,
20255                profile_revision,
20256                network_allowlist: None,
20257                browser_profile_id: None,
20258            },
20259            root,
20260            deny,
20261        )
20262    }
20263
20264    #[cfg(feature = "fixture")]
20265    #[derive(Clone, Copy)]
20266    enum CodexHomeProfile {
20267        Missing,
20268        WrongClass,
20269        Exact,
20270    }
20271
20272    #[cfg(feature = "fixture")]
20273    fn codex_bundle_test_shared(
20274        home_profile: CodexHomeProfile,
20275    ) -> (
20276        NodeShared,
20277        NativeRuntime,
20278        ResolvedEnvironmentProfileReceipt,
20279        NodeBundle,
20280        PathBuf,
20281    ) {
20282        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20283        const CLAUDE_MANIFEST: &[u8] =
20284            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20285        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20286        const DIGEST: &str =
20287            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20288
20289        let (shared, mut runtime, _claude_receipt, root, _deny) =
20290            materialization_test_shared(false);
20291        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20292            CodexHomeProfile::Missing => "codex-home-missing",
20293            CodexHomeProfile::WrongClass => "codex-home-wrong",
20294            CodexHomeProfile::Exact => "codex-home-exact",
20295        })
20296        .unwrap();
20297        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20298            "codex-home-r1",
20299        )
20300        .unwrap();
20301        let materialization = match home_profile {
20302            CodexHomeProfile::Missing => None,
20303            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20304                Some(NodeSessionMaterializationProfile::new(
20305                    Vec::new(),
20306                    vec![NodeSessionPathBinding::new(
20307                        "CODEX_HOME",
20308                        if matches!(home_profile, CodexHomeProfile::Exact) {
20309                            NodeSessionPathClass::ProviderHome
20310                        } else {
20311                            NodeSessionPathClass::Config
20312                        },
20313                    )
20314                    .unwrap()],
20315                    Vec::new(),
20316                )
20317                .unwrap())
20318            }
20319        };
20320        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20321            profile_id.clone(),
20322            profile_revision.clone(),
20323            agent("codex"),
20324            [NativeLaunchProfile::new(
20325                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20326                agent("codex"),
20327                TransportKind::Pty,
20328                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20329                Arc::new(EmptyEnvironmentResolver),
20330            )
20331            .unwrap()],
20332            materialization,
20333        )
20334        .unwrap();
20335        let (binding, native_profiles, materialization) = node_profile.into_parts();
20336        for native_profile in native_profiles {
20337            runtime.upsert_native_launch_profile(native_profile).unwrap();
20338        }
20339        shared
20340            .environment_profiles
20341            .write()
20342            .unwrap_or_else(|poisoned| poisoned.into_inner())
20343            .insert(binding.id.clone(), binding);
20344        if let Some(materialization) = materialization {
20345            shared
20346                .environment_materialization_profiles
20347                .write()
20348                .unwrap_or_else(|poisoned| poisoned.into_inner())
20349                .insert(profile_id.clone(), materialization);
20350        }
20351
20352        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20353        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20354        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20355        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20356        std::fs::write(
20357            bundle_source.join(".claude-plugin/plugin.json"),
20358            CLAUDE_MANIFEST,
20359        )
20360        .unwrap();
20361        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20362        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20363        let bundle = NodeBundle::new(
20364            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20365            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20366            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20367            &bundle_source,
20368        )
20369        .unwrap();
20370        *shared
20371            .bundle_catalog
20372            .write()
20373            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20374            BundleCatalog::new([bundle.clone()]).unwrap();
20375        (
20376            shared,
20377            runtime,
20378            ResolvedEnvironmentProfileReceipt {
20379                profile_id,
20380                profile_revision,
20381                network_allowlist: None,
20382                browser_profile_id: None,
20383            },
20384            bundle,
20385            root,
20386        )
20387    }
20388
20389    #[cfg(feature = "fixture")]
20390    #[test]
20391    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20392        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20393            let (shared, runtime, receipt, bundle, root) =
20394                codex_bundle_test_shared(home_profile);
20395            let error = shared
20396                .bundle_layout(
20397                    &agent("codex"),
20398                    SessionMode::Pty,
20399                    Some(&receipt),
20400                    &bundle,
20401                )
20402                .unwrap_err();
20403            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20404            assert!(shared
20405                .materializations
20406                .lock()
20407                .unwrap_or_else(|poisoned| poisoned.into_inner())
20408                .is_empty());
20409            let materialization_root = root.join("session-environments");
20410            assert!(!std::fs::read_dir(&materialization_root)
20411                .unwrap()
20412                .filter_map(Result::ok)
20413                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20414            drop(shared);
20415            drop(runtime);
20416            let _ = std::fs::remove_dir_all(root);
20417        }
20418    }
20419
20420    #[cfg(feature = "fixture")]
20421    #[test]
20422    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20423        let (shared, runtime, receipt, bundle, root) =
20424            codex_bundle_test_shared(CodexHomeProfile::Exact);
20425        let address = SessionAddress {
20426            workspace_id: WorkspaceId::new("primary").unwrap(),
20427            session: SessionKey {
20428                instance_id: AgentInstanceId(310),
20429                generation: SessionGeneration(1),
20430            },
20431        };
20432        let (overlay, guard) = shared
20433            .prepare_session_materialization(
20434                &address,
20435                &agent("codex"),
20436                SessionMode::Pty,
20437                Some(&receipt),
20438                Some(&bundle.receipt()),
20439                None,
20440                None,
20441            )
20442            .unwrap()
20443            .unwrap();
20444        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20445        let ownership = shared
20446            .materializations
20447            .lock()
20448            .unwrap_or_else(|poisoned| poisoned.into_inner())
20449            .get(guard.id().unwrap())
20450            .unwrap()
20451            .clone();
20452        let (profile, _, _) = shared
20453            .materialization_profile(
20454                &agent("codex"),
20455                SessionMode::Pty,
20456                Some(&receipt),
20457                Some(&bundle.receipt()),
20458                None,
20459            )
20460            .unwrap()
20461            .unwrap();
20462        let environment = shared
20463            .session_environment_materializer
20464            .as_ref()
20465            .unwrap()
20466            .resolve_environment(&ownership, &profile)
20467            .unwrap();
20468        assert!(environment.iter().any(|mutation| {
20469            mutation.key == OsString::from("CODEX_HOME")
20470                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20471        }));
20472        assert_eq!(
20473            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20474                .unwrap(),
20475            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20476        );
20477        assert!(std::fs::read_dir(ownership.bundle_root())
20478            .unwrap()
20479            .next()
20480            .is_none());
20481        drop(guard);
20482        drop(shared);
20483        drop(runtime);
20484        let _ = std::fs::remove_dir_all(root);
20485    }
20486
20487    #[cfg(feature = "fixture")]
20488    #[test]
20489    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20490        let (shared, runtime, receipt, bundle, root) =
20491            codex_bundle_test_shared(CodexHomeProfile::Exact);
20492        let bundle_receipt = bundle.receipt();
20493        let address = SessionAddress {
20494            workspace_id: WorkspaceId::new("primary").unwrap(),
20495            session: SessionKey {
20496                instance_id: AgentInstanceId(311),
20497                generation: SessionGeneration(1),
20498            },
20499        };
20500        let (_overlay, guard) = shared
20501            .prepare_session_materialization(
20502                &address,
20503                &agent("codex"),
20504                SessionMode::Pty,
20505                Some(&receipt),
20506                Some(&bundle_receipt),
20507                None,
20508                None,
20509            )
20510            .unwrap()
20511            .unwrap();
20512        let materialization_id = guard.id().unwrap().clone();
20513        let skill_path = shared
20514            .materializations
20515            .lock()
20516            .unwrap_or_else(|poisoned| poisoned.into_inner())
20517            .get(&materialization_id)
20518            .unwrap()
20519            .provider_home()
20520            .join("skills/review-code/SKILL.md");
20521        let record_id = shared
20522            .bind_spawn_session_with_materialization(
20523                &address,
20524                agent("codex"),
20525                SessionMode::Pty,
20526                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20527                SpawnRecordPolicy::ProviderIdentityOnly,
20528                Some(receipt.clone()),
20529                Some(bundle_receipt.clone()),
20530                None,
20531                Some(materialization_id.clone()),
20532            )
20533            .unwrap()
20534            .unwrap();
20535        guard.retain();
20536        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20537
20538        let error = match shared.resolve_record_materialization(
20539                &record_id,
20540                &agent("codex"),
20541                SessionMode::Pty,
20542                Some(&receipt),
20543                Some(&bundle_receipt),
20544                None,
20545            ) {
20546                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20547                Err(error) => error,
20548            };
20549        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20550        assert_eq!(
20551            shared
20552                .materializations
20553                .lock()
20554                .unwrap_or_else(|poisoned| poisoned.into_inner())
20555                .get(&materialization_id)
20556                .unwrap()
20557                .state(),
20558            MaterializationState::RecoveryRequired,
20559        );
20560        drop(shared);
20561        drop(runtime);
20562        let _ = std::fs::remove_dir_all(root);
20563    }
20564
20565    fn bind_record_owned_materialization_fixture(
20566        shared: &NodeShared,
20567        receipt: &ResolvedEnvironmentProfileReceipt,
20568        instance_id: u64,
20569    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20570        let address = SessionAddress {
20571            workspace_id: WorkspaceId::new("primary").unwrap(),
20572            session: SessionKey {
20573                instance_id: AgentInstanceId(instance_id),
20574                generation: SessionGeneration(1),
20575            },
20576        };
20577        let (overlay, materialization_guard) = shared
20578            .prepare_session_materialization(
20579                &address,
20580                &agent("claude"),
20581                SessionMode::Pty,
20582                Some(receipt),
20583                None,
20584                None,
20585                None,
20586            )
20587            .unwrap()
20588            .unwrap();
20589        let materialization_id = materialization_guard.id().unwrap().clone();
20590        let materialization_root = shared
20591            .materializations
20592            .lock()
20593            .unwrap_or_else(|poisoned| poisoned.into_inner())
20594            .get(&materialization_id)
20595            .unwrap()
20596            .root()
20597            .to_path_buf();
20598        let selection = shared
20599            .select_environment_profile(
20600                address.session.instance_id,
20601                &agent("claude"),
20602                SessionMode::Pty,
20603                Some(receipt),
20604            )
20605            .unwrap()
20606            .unwrap();
20607        assert!(shared
20608            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20609            .unwrap()
20610            .is_none());
20611        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20612        let record_id = shared
20613            .bind_spawn_session_with_materialization(
20614                &address,
20615                agent("claude"),
20616                SessionMode::Pty,
20617                policy,
20618                SpawnRecordPolicy::ProviderIdentityOnly,
20619                Some(receipt.clone()),
20620                None,
20621                None,
20622                Some(materialization_id.clone()),
20623            )
20624            .unwrap()
20625            .unwrap();
20626        selection.retain();
20627        materialization_guard.retain();
20628        (address, record_id, materialization_id, materialization_root)
20629    }
20630
20631    #[test]
20632    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20633        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20634        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20635            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20636        }));
20637        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20638        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20639        let mut spec = spawn_spec_fixture();
20640        spec.expected_profile_revision =
20641            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20642        assert!(matches!(
20643            &spec.overrides.environment_profile_id,
20644            crate::protocol::SpawnOverride::Inherit,
20645        ));
20646        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20647            .requires_child_environment_profile_capability());
20648        assert!(request_requires_child_environment_profile(
20649            &shared,
20650            &NodeRequest::SpawnSpec { spec: spec.clone() },
20651        ));
20652
20653        let address = terminal_address(1);
20654        shared.bind_session_with_policy(
20655            &address,
20656            ProviderRuntimePolicy::raw_pty(),
20657            Some(environment_profile.clone()),
20658        );
20659        assert!(request_requires_child_environment_profile(
20660            &shared,
20661            &NodeRequest::Input {
20662                session: address.clone(),
20663                text: "x".to_owned(),
20664            },
20665        ));
20666
20667        let mut record = record("claude", "profile-record");
20668        record.active_session = Some(address.clone());
20669        record.environment_profile = Some(environment_profile.clone());
20670        let record_id = record.record_id.clone();
20671        shared.insert_record(record.clone()).unwrap();
20672        assert!(request_requires_child_environment_profile(
20673            &shared,
20674            &NodeRequest::ResumeSessionRecord {
20675                record_id,
20676                terminal_size: gate4agent_types::TerminalSize {
20677                    rows: 24,
20678                    columns: 80,
20679                },
20680                initial_prompt: None,
20681            },
20682        ));
20683
20684        let snapshot = snapshot_for_wire(
20685            &shared, false, true, true, true, false, false, false, false, true,
20686        );
20687        assert_eq!(snapshot.session_records.len(), 1);
20688        assert!(snapshot.session_records[0].environment_profile.is_none());
20689        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20690            sequence: 1,
20691            event: NodeEvent::SessionRecordUpserted { record },
20692        });
20693        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20694            panic!("record projection changed the event kind");
20695        };
20696        assert!(record.environment_profile.is_none());
20697
20698        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20699        let mut reply = ResponseEnvelope {
20700            request_id: 1,
20701            result: Ok(NodeResponse::SpawnSpecAccepted {
20702                receipt: resolved.receipt_with_environment(
20703                    shared.incarnation_id,
20704                    address,
20705                    Some(environment_profile),
20706                ),
20707            }),
20708        };
20709        project_response_without_child_environment_profile(&mut reply);
20710        assert_eq!(
20711            reply.result.unwrap_err().code,
20712            NodeFailureCode::UnsupportedCapability,
20713        );
20714    }
20715
20716    #[test]
20717    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20718        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20719        let snapshot = shared.snapshot();
20720        let profiles = snapshot
20721            .launch_inventory
20722            .as_ref()
20723            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20724            .unwrap();
20725        assert_eq!(profiles.len(), 1);
20726        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20727        assert!(snapshot.requires_child_environment_profile_capability());
20728
20729        let mut legacy = snapshot.clone();
20730        clear_snapshot_child_environment_profiles(&mut legacy);
20731        assert!(legacy
20732            .launch_inventory
20733            .as_ref()
20734            .unwrap()
20735            .spawn_profiles
20736            .as_ref()
20737            .unwrap()[0]
20738            .environment_profile
20739            .is_none());
20740        assert!(!legacy.requires_child_environment_profile_capability());
20741
20742        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20743        let bindings = shared
20744            .environment_profiles
20745            .read()
20746            .unwrap_or_else(|poisoned| poisoned.into_inner())
20747            .clone();
20748        let mut provider_mismatch = bindings.clone();
20749        provider_mismatch
20750            .values_mut()
20751            .next()
20752            .unwrap()
20753            .provider = agent("codex");
20754        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20755        let mut mode_mismatch = profile;
20756        mode_mismatch.mode = SessionMode::Inline;
20757        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20758
20759        shared
20760            .environment_profiles
20761            .write()
20762            .unwrap_or_else(|poisoned| poisoned.into_inner())
20763            .clear();
20764        assert!(shared
20765            .snapshot()
20766            .launch_inventory
20767            .unwrap()
20768            .spawn_profiles
20769            .unwrap()
20770            .is_empty());
20771    }
20772
20773    #[test]
20774    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20775        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20776            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20777        }));
20778        let shared = terminal_test_shared();
20779        let bundle = ResolvedBundleReceipt {
20780            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20781            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20782            digest: crate::protocol::SpawnBundleDigest::new(format!(
20783                "sha256:{}",
20784                "0".repeat(64),
20785            ))
20786            .unwrap(),
20787        };
20788        let mut bundled = record("claude", "bundle-record");
20789        bundled.bundle = Some(bundle.clone());
20790        let record_id = bundled.record_id.clone();
20791        shared.insert_record(bundled.clone()).unwrap();
20792        assert!(request_requires_session_bundle(
20793            &shared,
20794            &NodeRequest::ForgetSessionRecord {
20795                record_id: record_id.clone(),
20796            },
20797        ));
20798
20799        let snapshot = snapshot_for_wire(
20800            &shared, false, true, true, true, true, false, false, false, true,
20801        );
20802        assert!(snapshot.session_records[0].bundle.is_none());
20803        let launch = snapshot.launch_inventory.as_ref().unwrap();
20804        assert!(launch.spawn_profiles.is_some());
20805        assert!(launch.bundles.is_none());
20806        let event = project_event_without_session_bundle(NodeEventEnvelope {
20807            sequence: 1,
20808            event: NodeEvent::SessionRecordUpserted { record: bundled },
20809        });
20810        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20811            panic!("record projection changed the event kind");
20812        };
20813        assert!(record.bundle.is_none());
20814
20815        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20816        let mut reply = ResponseEnvelope {
20817            request_id: 1,
20818            result: Ok(NodeResponse::SpawnSpecAccepted {
20819                receipt: resolved.receipt_with_materialization(
20820                    shared.incarnation_id,
20821                    terminal_address(17),
20822                    None,
20823                    Some(bundle),
20824                    None,
20825                ),
20826            }),
20827        };
20828        project_response_without_session_bundle(&mut reply);
20829        assert_eq!(
20830            reply.result.unwrap_err().code,
20831            NodeFailureCode::UnsupportedCapability,
20832        );
20833    }
20834
20835    #[test]
20836    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20837        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20838            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20839        }));
20840        let context_id = SpawnContextId::new("context-review").unwrap();
20841        let context = ResolvedContextPackReceipt {
20842            id: context_id.clone(),
20843            digest: crate::protocol::SpawnContextDigest::new(format!(
20844                "sha256:{}",
20845                "c".repeat(64),
20846            ))
20847            .unwrap(),
20848            lineage: ContextPackLineageReceipt {
20849                source_node_id: NodeId::new("node-source").unwrap(),
20850                source_session: terminal_address(1),
20851                source_provider: agent("kimi"),
20852            },
20853            source_message_count: 2,
20854            retained_message_count: 2,
20855            byte_len: 256,
20856            truncated: false,
20857        };
20858        assert!(context.is_valid());
20859        let mut bound_record = record("codex", "context-record");
20860        bound_record.context_id = Some(context_id);
20861        bound_record.context = Some(context.clone());
20862        let session = gate4agent_types::SessionSnapshot {
20863            instance_id: AgentInstanceId(91),
20864            agent_id: agent("kimi"),
20865            transport: TransportKind::Pty,
20866            generation: SessionGeneration(3),
20867            status: SessionStatus::Running,
20868            pending_operation: None,
20869            pending_input: None,
20870            process_id: Some(9001),
20871            terminal_size: None,
20872            terminal_frame: None,
20873            terminal_stale: None,
20874            session_options: None,
20875            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20876            history: gate4agent_types::HistorySnapshot {
20877                pending: None,
20878                candidates: vec![HistoryCandidateSummary {
20879                    id: "candidate-opaque".to_owned(),
20880                    session_id_hint: "session-hint".to_owned(),
20881                    modified_at_unix_ms: Some(1),
20882                }],
20883                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20884                loaded: Some(HistorySessionRecord {
20885                    session_id: "private-vendor-session".to_owned(),
20886                    title: Some("private title".to_owned()),
20887                    cwd: Some(r"C:\private\history-root".to_owned()),
20888                    model: Some("private-model".to_owned()),
20889                    message_count: 2,
20890                    completed_turn_count: None,
20891                    total_tokens: 19,
20892                    messages: vec![
20893                        gate4agent_types::HistoryMessageRecord {
20894                            role: gate4agent_types::HistoryMessageRole::User,
20895                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20896                        },
20897                        gate4agent_types::HistoryMessageRecord {
20898                            role: gate4agent_types::HistoryMessageRole::Assistant,
20899                            text: "private answer".to_owned(),
20900                        },
20901                    ],
20902                }),
20903                last_error: None,
20904            },
20905            resume: gate4agent_types::ResumeSnapshot::default(),
20906            foreground: gate4agent_types::ForegroundSnapshot::default(),
20907            provider: gate4agent_types::ProviderSnapshot::default(),
20908            screen_state: gate4agent_types::PtyScreenState::default(),
20909        };
20910        let original = NodeSnapshot {
20911            node_id: NodeId::new("node-terminal-test").unwrap(),
20912            enabled_providers: vec![agent("kimi"), agent("codex")],
20913            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20914            workspaces: vec![WorkspaceSnapshot {
20915                workspace_id: WorkspaceId::new("primary").unwrap(),
20916                canonical_root: opaque_windows_path(
20917                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20918                ),
20919                sessions: vec![session],
20920                worktree_service_mode: None,
20921                managed_worktree_profiles: None,
20922            }],
20923            session_records: vec![bound_record],
20924            managed_worktrees: Vec::new(),
20925            launch_inventory: None,
20926            agent_progress: Vec::new(),
20927        };
20928
20929        let mut negotiated = original.clone();
20930        project_snapshot_history_for_wire(&mut negotiated, true);
20931        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20932        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20933        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20934        assert!(!serde_json::to_string(&negotiated)
20935            .unwrap()
20936            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20937
20938        let mut legacy = original;
20939        project_snapshot_history_for_wire(&mut legacy, false);
20940        clear_snapshot_context_packs(&mut legacy);
20941        assert_eq!(
20942            legacy.workspaces[0].sessions[0].history,
20943            gate4agent_types::HistorySnapshot::default(),
20944        );
20945        assert!(legacy.session_records[0].context_id.is_none());
20946        assert!(legacy.session_records[0].context.is_none());
20947        let encoded = serde_json::to_string(&legacy).unwrap();
20948        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20949        assert!(!encoded.contains("context-review"));
20950
20951        let mut reply = ResponseEnvelope {
20952            request_id: 7,
20953            result: Ok(NodeResponse::ContextPackExported { context }),
20954        };
20955        project_response_without_context_pack(&mut reply);
20956        assert_eq!(
20957            reply.result.unwrap_err().code,
20958            NodeFailureCode::UnsupportedCapability,
20959        );
20960    }
20961
20962    #[test]
20963    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20964        let shared = terminal_test_shared();
20965        let history = HistorySessionRecord {
20966            session_id: "fixture-history".to_owned(),
20967            title: None,
20968            cwd: None,
20969            model: None,
20970            message_count: 1,
20971            completed_turn_count: None,
20972            total_tokens: 0,
20973            messages: vec![gate4agent_types::HistoryMessageRecord {
20974                role: gate4agent_types::HistoryMessageRole::User,
20975                text: "bounded handoff".to_owned(),
20976            }],
20977        };
20978        let pack = NodeContextPack::export(
20979            ContextPackLineageReceipt {
20980                source_node_id: shared.node_id.clone(),
20981                source_session: terminal_address(1),
20982                source_provider: agent("claude"),
20983            },
20984            &history,
20985        )
20986        .unwrap();
20987        let context = shared
20988            .context_catalog
20989            .write()
20990            .unwrap_or_else(|poisoned| poisoned.into_inner())
20991            .insert(pack)
20992            .unwrap();
20993        let mut retained = record("codex", "context-retained-record");
20994        retained.context_id = Some(context.id.clone());
20995        retained.context = Some(context.clone());
20996        let record_id = retained.record_id.clone();
20997        shared.insert_record(retained).unwrap();
20998        assert_eq!(
20999            shared.forget_context_pack(&context.id).unwrap_err().code,
21000            NodeFailureCode::ContextPackBusy,
21001        );
21002        assert!(shared
21003            .context_catalog
21004            .read()
21005            .unwrap_or_else(|poisoned| poisoned.into_inner())
21006            .get(&context.id)
21007            .is_some());
21008        shared
21009            .session_records
21010            .lock()
21011            .unwrap_or_else(|poisoned| poisoned.into_inner())
21012            .records
21013            .remove(&record_id);
21014
21015        let mut spec = spawn_spec_fixture();
21016        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21017            value: context.id.clone(),
21018        };
21019        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21020        let receipt = resolved.receipt_with_materialization(
21021            shared.incarnation_id,
21022            terminal_address(2),
21023            None,
21024            None,
21025            Some(context.clone()),
21026        );
21027        assert!(receipt.context_binding_is_valid());
21028        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21029
21030        shared.forget_context_pack(&context.id).unwrap();
21031        assert!(shared
21032            .context_catalog
21033            .read()
21034            .unwrap_or_else(|poisoned| poisoned.into_inner())
21035            .get(&context.id)
21036            .is_none());
21037        assert!(shared
21038            .replay_spawn_spec(&spec, Instant::now())
21039            .unwrap()
21040            .is_none());
21041    }
21042
21043    #[test]
21044    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21045        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21046        let address = terminal_address(1);
21047        let selection = shared
21048            .select_environment_profile(
21049                address.session.instance_id,
21050                &agent("claude"),
21051                SessionMode::Pty,
21052                Some(&environment_profile),
21053            )
21054            .unwrap()
21055            .unwrap();
21056        shared.bind_session_with_policy(
21057            &address,
21058            ProviderRuntimePolicy::raw_pty(),
21059            Some(environment_profile.clone()),
21060        );
21061        selection.retain();
21062        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21063        assert!(matches!(
21064            runtime.remove_native_launch_profile(&native_id),
21065            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21066        ));
21067        assert!(shared.remove_binding(&address).is_some());
21068        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21069
21070        let mismatched = ResolvedEnvironmentProfileReceipt {
21071            profile_id: environment_profile.profile_id,
21072            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21073                "local-claude-r2",
21074            )
21075            .unwrap(),
21076            network_allowlist: None,
21077            browser_profile_id: None,
21078        };
21079        let error = match shared.select_environment_profile(
21080                AgentInstanceId(42),
21081                &agent("claude"),
21082                SessionMode::Pty,
21083                Some(&mismatched),
21084            ) {
21085            Ok(_) => panic!("mismatched environment profile revision was selected"),
21086            Err(error) => error,
21087        };
21088        assert_eq!(
21089            error.code,
21090            NodeFailureCode::EnvironmentProfileBindingMismatch,
21091        );
21092        assert!(!shared
21093            .session_bindings
21094            .lock()
21095            .unwrap_or_else(|poisoned| poisoned.into_inner())
21096            .contains_key(&AgentInstanceId(42)));
21097    }
21098
21099    #[test]
21100    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21101        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21102        let address = terminal_address(1);
21103        let selection = shared
21104            .select_environment_profile(
21105                address.session.instance_id,
21106                &agent("claude"),
21107                SessionMode::Pty,
21108                Some(&environment_profile),
21109            )
21110            .unwrap()
21111            .unwrap();
21112        let overlay = NativeInstanceLaunchOverlay::new(
21113            agent("claude"),
21114            TransportKind::Pty,
21115            vec![EnvMutation {
21116                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21117                value: Some(OsString::from("fixture-secret")),
21118            }],
21119            vec![
21120                OsString::from("--plugin-dir"),
21121                std::env::current_dir().unwrap().into_os_string(),
21122            ],
21123        )
21124        .unwrap();
21125        let instance_overlay = shared
21126            .install_prepared_launch_overlay(
21127                address.session.instance_id,
21128                PreparedNativeLaunchOverlay::Instance(overlay),
21129            )
21130            .unwrap()
21131            .unwrap();
21132        let bundle = ResolvedBundleReceipt {
21133            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21134            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21135            digest: crate::protocol::SpawnBundleDigest::new(format!(
21136                "sha256:{}",
21137                "0".repeat(64),
21138            ))
21139            .unwrap(),
21140        };
21141        shared.bind_session_with_materialization(
21142            &address,
21143            ProviderRuntimePolicy::raw_pty(),
21144            Some(environment_profile),
21145            Some(bundle),
21146            None,
21147            None,
21148        );
21149        selection.retain();
21150        instance_overlay.retain();
21151
21152        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21153        assert!(matches!(
21154            runtime.remove_native_launch_profile(&native_id),
21155            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21156        ));
21157        assert!(shared.remove_binding(&address).is_some());
21158        assert!(!shared
21159            .native_launch_profile_control
21160            .as_ref()
21161            .unwrap()
21162            .clear_native_instance_launch_overlay(address.session.instance_id));
21163        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21164    }
21165
21166    #[test]
21167    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21168        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21169        let address = SessionAddress {
21170            workspace_id: WorkspaceId::new("primary").unwrap(),
21171            session: SessionKey {
21172                instance_id: AgentInstanceId(201),
21173                generation: SessionGeneration(1),
21174            },
21175        };
21176        let error = match shared.prepare_session_materialization(
21177            &address,
21178            &agent("claude"),
21179            SessionMode::Pty,
21180            Some(&receipt),
21181            None,
21182            None,
21183            None,
21184        ) {
21185            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21186            Err(error) => error,
21187        };
21188        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21189        assert!(shared.handle.snapshot().sessions.is_empty());
21190        assert!(shared
21191            .session_bindings
21192            .lock()
21193            .unwrap_or_else(|poisoned| poisoned.into_inner())
21194            .is_empty());
21195        assert!(shared
21196            .materializations
21197            .lock()
21198            .unwrap_or_else(|poisoned| poisoned.into_inner())
21199            .is_empty());
21200        drop(shared);
21201        drop(runtime);
21202        std::fs::remove_dir_all(root).unwrap();
21203    }
21204
21205    #[test]
21206    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21207        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21208        let address = SessionAddress {
21209            workspace_id: WorkspaceId::new("primary").unwrap(),
21210            session: SessionKey {
21211                instance_id: AgentInstanceId(202),
21212                generation: SessionGeneration(1),
21213            },
21214        };
21215        let (overlay, materialization_guard) = shared
21216            .prepare_session_materialization(
21217                &address,
21218                &agent("claude"),
21219                SessionMode::Pty,
21220                Some(&receipt),
21221                None,
21222                None,
21223                None,
21224            )
21225            .unwrap()
21226            .unwrap();
21227        let materialization_id = materialization_guard.id().unwrap().clone();
21228        let materialization_root = shared
21229            .materializations
21230            .lock()
21231            .unwrap_or_else(|poisoned| poisoned.into_inner())
21232            .get(&materialization_id)
21233            .unwrap()
21234            .root()
21235            .to_path_buf();
21236        let selection = shared
21237            .select_environment_profile(
21238                address.session.instance_id,
21239                &agent("claude"),
21240                SessionMode::Pty,
21241                Some(&receipt),
21242            )
21243            .unwrap()
21244            .unwrap();
21245        assert!(shared
21246            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21247            .unwrap()
21248            .is_none());
21249        shared.bind_session_with_materialization(
21250            &address,
21251            ProviderRuntimePolicy::raw_pty(),
21252            Some(receipt),
21253            None,
21254            None,
21255            Some(materialization_id),
21256        );
21257        selection.retain();
21258        materialization_guard.retain();
21259
21260        assert!(materialization_root.is_dir());
21261        assert!(shared
21262            .session_bindings
21263            .lock()
21264            .unwrap_or_else(|poisoned| poisoned.into_inner())
21265            .contains_key(&address.session.instance_id));
21266
21267        let removed = shared.remove_binding(&address).unwrap();
21268        shared
21269            .cleanup_session_owned_materialization(&address, &removed)
21270            .unwrap();
21271        assert!(!materialization_root.exists());
21272        assert!(shared
21273            .materializations
21274            .lock()
21275            .unwrap_or_else(|poisoned| poisoned.into_inner())
21276            .is_empty());
21277        drop(shared);
21278        drop(runtime);
21279        std::fs::remove_dir_all(root).unwrap();
21280    }
21281
21282    #[tokio::test]
21283    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21284        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21285        let address = SessionAddress {
21286            workspace_id: WorkspaceId::new("primary").unwrap(),
21287            session: SessionKey {
21288                instance_id: AgentInstanceId(203),
21289                generation: SessionGeneration(1),
21290            },
21291        };
21292        let (overlay, materialization_guard) = shared
21293            .prepare_session_materialization(
21294                &address,
21295                &agent("claude"),
21296                SessionMode::Pty,
21297                Some(&receipt),
21298                None,
21299                None,
21300                None,
21301            )
21302            .unwrap()
21303            .unwrap();
21304        let materialization_id = materialization_guard.id().unwrap().clone();
21305        let materialization_root = shared
21306            .materializations
21307            .lock()
21308            .unwrap_or_else(|poisoned| poisoned.into_inner())
21309            .get(&materialization_id)
21310            .unwrap()
21311            .root()
21312            .to_path_buf();
21313        let selection = shared
21314            .select_environment_profile(
21315                address.session.instance_id,
21316                &agent("claude"),
21317                SessionMode::Pty,
21318                Some(&receipt),
21319            )
21320            .unwrap()
21321            .unwrap();
21322        assert!(shared
21323            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21324            .unwrap()
21325            .is_none());
21326        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21327        let record_id = shared
21328            .bind_spawn_session_with_materialization(
21329                &address,
21330                agent("claude"),
21331                SessionMode::Pty,
21332                policy,
21333                SpawnRecordPolicy::ProviderIdentityOnly,
21334                Some(receipt.clone()),
21335                None,
21336                None,
21337                Some(materialization_id.clone()),
21338            )
21339            .unwrap()
21340            .unwrap();
21341        selection.retain();
21342        materialization_guard.retain();
21343        assert_eq!(
21344            shared
21345                .materializations
21346                .lock()
21347                .unwrap_or_else(|poisoned| poisoned.into_inner())
21348                .get(&materialization_id)
21349                .unwrap()
21350                .owner(),
21351            &MaterializationOwner::Record {
21352                record_id: record_id.clone(),
21353            },
21354        );
21355
21356        let removed = shared.remove_binding(&address).unwrap();
21357        shared
21358            .cleanup_session_owned_materialization(&address, &removed)
21359            .unwrap();
21360        assert!(materialization_root.is_dir());
21361        assert!(shared
21362            .resolve_record_materialization(
21363                &record_id,
21364                &agent("claude"),
21365                SessionMode::Pty,
21366                Some(&receipt),
21367                None,
21368                None,
21369            )
21370            .unwrap()
21371            .is_some());
21372        {
21373            let mut records = shared
21374                .session_records
21375                .lock()
21376                .unwrap_or_else(|poisoned| poisoned.into_inner());
21377            let record = records.records.get_mut(&record_id).unwrap();
21378            record.active_session = None;
21379            record.state = ManagedSessionState::Unavailable;
21380        }
21381        shared.forget_session_record(&record_id).await.unwrap();
21382        assert!(!materialization_root.exists());
21383        assert!(shared
21384            .materializations
21385            .lock()
21386            .unwrap_or_else(|poisoned| poisoned.into_inner())
21387            .is_empty());
21388        drop(shared);
21389        drop(runtime);
21390        std::fs::remove_dir_all(root).unwrap();
21391    }
21392
21393    #[test]
21394    fn record_materialization_revalidation_failure_persists_recovery_required() {
21395        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21396        let state_path = root.join("node-state-v6.json");
21397        shared.state_path = Some(state_path.clone());
21398        let (_address, _record_id, materialization_id, materialization_root) =
21399            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21400        std::fs::write(
21401            materialization_root.join(".gate4agent-materialization-owner"),
21402            b"tampered",
21403        )
21404        .unwrap();
21405
21406        let error = match shared.resolve_record_materialization(
21407            &_record_id,
21408            &agent("claude"),
21409            SessionMode::Pty,
21410            Some(&receipt),
21411            None,
21412            None,
21413        ) {
21414            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21415            Err(error) => error,
21416        };
21417        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21418        assert_eq!(
21419            shared
21420                .materializations
21421                .lock()
21422                .unwrap_or_else(|poisoned| poisoned.into_inner())
21423                .get(&materialization_id)
21424                .unwrap()
21425                .state(),
21426            MaterializationState::RecoveryRequired,
21427        );
21428        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21429        assert_eq!(loaded.materializations.len(), 1);
21430        assert_eq!(
21431            loaded.materializations[0].state(),
21432            MaterializationState::RecoveryRequired,
21433        );
21434        drop(shared);
21435        drop(runtime);
21436        std::fs::remove_dir_all(root).unwrap();
21437    }
21438
21439    #[test]
21440    fn transient_record_materialization_resolution_failure_remains_ready() {
21441        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21442        let (_address, record_id, materialization_id, _materialization_root) =
21443            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21444        deny.store(true, Ordering::Release);
21445
21446        let error = match shared.resolve_record_materialization(
21447            &record_id,
21448            &agent("claude"),
21449            SessionMode::Pty,
21450            Some(&receipt),
21451            None,
21452            None,
21453        ) {
21454            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21455            Err(error) => error,
21456        };
21457        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21458        assert_eq!(
21459            shared
21460                .materializations
21461                .lock()
21462                .unwrap_or_else(|poisoned| poisoned.into_inner())
21463                .get(&materialization_id)
21464                .unwrap()
21465                .state(),
21466            MaterializationState::Ready,
21467        );
21468        drop(shared);
21469        drop(runtime);
21470        std::fs::remove_dir_all(root).unwrap();
21471    }
21472
21473    #[test]
21474    fn startup_missing_record_materialization_is_not_resumable() {
21475        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21476        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21477        shared
21478            .insert_record(ManagedSessionRecord {
21479                record_id: record_id.clone(),
21480                display_name: "missing materialization".to_owned(),
21481                provider: agent("claude"),
21482                mode: SessionMode::Pty,
21483                state: ManagedSessionState::Dormant,
21484                workspace_id: WorkspaceId::new("primary").unwrap(),
21485                canonical_root: opaque_windows_path(
21486                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21487                ),
21488                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21489                    key: gate4agent_types::ProviderSessionKey::SessionId,
21490                    id: "provider-session-missing-materialization".to_owned(),
21491                    transcript_path: None,
21492                }),
21493                active_session: None,
21494                environment_profile: Some(receipt),
21495                bundle: None,
21496                context_id: None,
21497                context: None,
21498                exported_context: None,
21499                task_binding: None,
21500                created_at_unix_ms: 1,
21501                updated_at_unix_ms: 1,
21502                last_error: None,
21503            })
21504            .unwrap();
21505
21506        shared.reconcile_materializations();
21507        let record = shared.record(&record_id).unwrap();
21508        assert_eq!(record.state, ManagedSessionState::Unavailable);
21509        assert!(record.provider_session.is_none());
21510        assert_eq!(
21511            record.last_error.as_deref(),
21512            Some("environment-profile-unavailable"),
21513        );
21514        drop(shared);
21515        drop(runtime);
21516        std::fs::remove_dir_all(root).unwrap();
21517    }
21518
21519    #[tokio::test]
21520    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21521        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21522        let lease = install_managed_lease(&shared);
21523        let address = SessionAddress {
21524            workspace_id: WorkspaceId::new("primary").unwrap(),
21525            session: SessionKey {
21526                instance_id: AgentInstanceId(204),
21527                generation: SessionGeneration(1),
21528            },
21529        };
21530        let (overlay, materialization_guard) = shared
21531            .prepare_session_materialization(
21532                &address,
21533                &agent("claude"),
21534                SessionMode::Pty,
21535                Some(&receipt),
21536                None,
21537                None,
21538                Some(lease.lease_id.clone()),
21539            )
21540            .unwrap()
21541            .unwrap();
21542        let materialization_id = materialization_guard.id().unwrap().clone();
21543        let materialization_root = shared
21544            .materializations
21545            .lock()
21546            .unwrap_or_else(|poisoned| poisoned.into_inner())
21547            .get(&materialization_id)
21548            .unwrap()
21549            .root()
21550            .to_path_buf();
21551        let selection = shared
21552            .select_environment_profile(
21553                address.session.instance_id,
21554                &agent("claude"),
21555                SessionMode::Pty,
21556                Some(&receipt),
21557            )
21558            .unwrap()
21559            .unwrap();
21560        assert!(shared
21561            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21562            .unwrap()
21563            .is_none());
21564        shared.bind_session_with_materialization(
21565            &address,
21566            ProviderRuntimePolicy::raw_pty(),
21567            Some(receipt),
21568            None,
21569            None,
21570            Some(materialization_id),
21571        );
21572        selection.retain();
21573        materialization_guard.retain();
21574
21575        let blocked = shared
21576            .cleanup_managed_worktree(&lease.lease_id, false)
21577            .await
21578            .unwrap_err();
21579        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21580        assert!(materialization_root.is_dir());
21581        let removed = shared.remove_binding(&address).unwrap();
21582        shared
21583            .cleanup_session_owned_materialization(&address, &removed)
21584            .unwrap();
21585        assert!(!materialization_root.exists());
21586        drop(shared);
21587        drop(runtime);
21588        std::fs::remove_dir_all(root).unwrap();
21589    }
21590
21591    #[tokio::test]
21592    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21593        let git_root = temporary_workspace_root("raw-managed-inventory");
21594        let source = git_root.join("source");
21595        let allocation = git_root.join("allocation");
21596        std::fs::create_dir_all(&source).unwrap();
21597        std::fs::create_dir_all(&allocation).unwrap();
21598        run_test_git(&source, &["init"]);
21599        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21600        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21601        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21602        run_test_git(&source, &["add", "seed.txt"]);
21603        run_test_git(&source, &["commit", "-m", "seed"]);
21604        let profile = ManagedWorktreeProfile::new(
21605            WorktreeProfileId::new("default").unwrap(),
21606            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21607            &allocation,
21608            "gate4agent",
21609            "HEAD",
21610            ManagedWorktreeRetention::RemoveWhenReleased,
21611        )
21612        .unwrap();
21613        let workspace = WorkspaceConfig::new(
21614            WorkspaceId::new("primary").unwrap(),
21615            &source,
21616        )
21617        .unwrap()
21618        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21619        .with_managed_worktree_profile(profile.clone())
21620        .unwrap();
21621        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21622            materialization_test_shared_with_workspace(false, workspace);
21623        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21624        let source_text = source.to_string_lossy().into_owned();
21625        let target_text = target.to_string_lossy().into_owned();
21626        let created = create_git_worktree(
21627            &source_text,
21628            &target_text,
21629            "gate4agent/mw-raw-inventory",
21630            Some("HEAD"),
21631        )
21632        .await
21633        .unwrap();
21634        let lease = ManagedWorktreeLeaseRecord {
21635            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21636            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21637            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21638            profile_id: profile.profile_id().clone(),
21639            profile_revision: profile.revision().clone(),
21640            target_root: created.path.clone(),
21641            branch: "gate4agent/mw-raw-inventory".to_owned(),
21642            base_commit: created.head.clone(),
21643            expected_head: Some(created.head.clone()),
21644            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21645            state: ManagedWorktreeLeaseState::Ready,
21646            session_holders: Vec::new(),
21647            record_holders: Vec::new(),
21648            cleanup_failure: None,
21649            created_at_unix_ms: 1,
21650            updated_at_unix_ms: 1,
21651        };
21652        shared
21653            .workspaces
21654            .write()
21655            .unwrap_or_else(|poisoned| poisoned.into_inner())
21656            .insert(lease.workspace_id.clone(), created.path.clone());
21657        *shared
21658            .managed_worktrees
21659            .lock()
21660            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21661            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21662        let address = SessionAddress {
21663            workspace_id: lease.workspace_id.clone(),
21664            session: SessionKey {
21665                instance_id: AgentInstanceId(205),
21666                generation: SessionGeneration(1),
21667            },
21668        };
21669        let (overlay, materialization_guard) = shared
21670            .prepare_session_materialization(
21671                &address,
21672                &agent("claude"),
21673                SessionMode::Pty,
21674                Some(&receipt),
21675                None,
21676                None,
21677                Some(lease.lease_id.clone()),
21678            )
21679            .unwrap()
21680            .unwrap();
21681        let materialization_id = materialization_guard.id().unwrap().clone();
21682        let materialization_root = shared
21683            .materializations
21684            .lock()
21685            .unwrap_or_else(|poisoned| poisoned.into_inner())
21686            .get(&materialization_id)
21687            .unwrap()
21688            .root()
21689            .to_path_buf();
21690        let selection = shared
21691            .select_environment_profile(
21692                address.session.instance_id,
21693                &agent("claude"),
21694                SessionMode::Pty,
21695                Some(&receipt),
21696            )
21697            .unwrap()
21698            .unwrap();
21699        assert!(shared
21700            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21701            .unwrap()
21702            .is_none());
21703        let record_id = shared
21704            .bind_spawn_session_with_materialization(
21705                &address,
21706                agent("claude"),
21707                SessionMode::Pty,
21708                ProviderRuntimePolicy::raw_pty(),
21709                SpawnRecordPolicy::Always,
21710                Some(receipt.clone()),
21711                None,
21712                None,
21713                Some(materialization_id.clone()),
21714            )
21715            .unwrap()
21716            .unwrap();
21717        selection.retain();
21718        materialization_guard.retain();
21719        let record = shared.record(&record_id).unwrap();
21720        assert_eq!(record.state, ManagedSessionState::Live);
21721        assert!(record.provider_session.is_none());
21722        assert!(record.environment_profile.is_none());
21723        assert!(record.bundle.is_none());
21724        assert!(record.context_id.is_none());
21725        assert!(record.context.is_none());
21726        assert_eq!(record.active_session.as_ref(), Some(&address));
21727        assert_eq!(
21728            shared
21729                .materializations
21730                .lock()
21731                .unwrap_or_else(|poisoned| poisoned.into_inner())
21732                .get(&materialization_id)
21733                .unwrap()
21734                .owner(),
21735            &MaterializationOwner::Session {
21736                incarnation_id: shared.incarnation_id,
21737                instance_id: address.session.instance_id,
21738                generation: address.session.generation,
21739            },
21740        );
21741        {
21742            let mut bindings = shared
21743                .session_bindings
21744                .lock()
21745                .unwrap_or_else(|poisoned| poisoned.into_inner());
21746            bindings
21747                .get_mut(&address.session.instance_id)
21748                .unwrap()
21749                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21750        }
21751        let lease_snapshot = shared
21752            .managed_worktrees
21753            .lock()
21754            .unwrap_or_else(|poisoned| poisoned.into_inner())
21755            .bind_session(
21756                &lease.lease_id,
21757                ManagedWorktreeSessionHolder {
21758                    incarnation_id: shared.incarnation_id,
21759                    instance_id: address.session.instance_id,
21760                    generation: address.session.generation,
21761                },
21762                None,
21763                unix_time_ms(),
21764            )
21765            .unwrap();
21766        assert_eq!(lease_snapshot.active_session_count, 1);
21767        assert_eq!(lease_snapshot.managed_record_count, 0);
21768        shared.persist_state().unwrap();
21769        assert!(shared
21770            .managed_worktrees
21771            .lock()
21772            .unwrap_or_else(|poisoned| poisoned.into_inner())
21773            .get(&lease.lease_id)
21774            .unwrap()
21775            .record_holders
21776            .is_empty());
21777        shared
21778            .handle
21779            .dispatch(shared.prepare_command(ControlCommand::Register {
21780                instance_id: address.session.instance_id,
21781                agent_id: agent("claude"),
21782                transport: TransportKind::Pty,
21783            }))
21784            .unwrap();
21785        runtime.tick().await;
21786        shared.reconcile_managed_record(
21787            &record_id,
21788            &address,
21789            &ControlEvent {
21790                sequence: 1,
21791                command_id: None,
21792                instance_id: address.session.instance_id,
21793                generation: address.session.generation,
21794                event: ControlEventKind::Removed,
21795            },
21796            false,
21797            false,
21798        );
21799        let remove = shared.remove_session(&address);
21800        let drive_runtime = async {
21801            runtime.tick().await;
21802        };
21803        let (removed, ()) = tokio::join!(remove, drive_runtime);
21804        removed.unwrap();
21805
21806        assert!(!materialization_root.exists());
21807        assert!(!Path::new(&created.path).exists());
21808        assert_eq!(
21809            shared
21810                .managed_worktrees
21811                .lock()
21812                .unwrap_or_else(|poisoned| poisoned.into_inner())
21813                .get(&lease.lease_id)
21814                .unwrap()
21815                .state,
21816            ManagedWorktreeLeaseState::Removed,
21817        );
21818        let renamed = shared
21819            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21820            .unwrap();
21821        assert_eq!(renamed.record_id, record_id);
21822        assert_eq!(renamed.display_name, "raw managed inventory");
21823        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21824        assert!(renamed.active_session.is_none());
21825
21826        drop(shared);
21827        drop(runtime);
21828        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21829        std::fs::remove_dir_all(git_root).unwrap();
21830    }
21831
21832    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21833        let root = std::env::current_dir().unwrap()
21834            .join("managed-test-target")
21835            .to_string_lossy()
21836            .into_owned();
21837        let lease = ManagedWorktreeLeaseRecord {
21838            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21839            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21840            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21841            profile_id: WorktreeProfileId::new("default").unwrap(),
21842            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21843            target_root: root.clone(),
21844            branch: "gate4agent/mw-test".to_owned(),
21845            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21846            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21847            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21848            state: ManagedWorktreeLeaseState::Ready,
21849            session_holders: Vec::new(),
21850            record_holders: Vec::new(),
21851            cleanup_failure: None,
21852            created_at_unix_ms: 1,
21853            updated_at_unix_ms: 1,
21854        };
21855        shared.workspaces.write()
21856            .unwrap_or_else(|poisoned| poisoned.into_inner())
21857            .insert(lease.workspace_id.clone(), root);
21858        *shared.managed_worktrees.lock()
21859            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21860            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21861        lease
21862    }
21863
21864    #[cfg(feature = "fixture")]
21865    #[test]
21866    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21867        let mut shared = terminal_test_shared();
21868        shared.fixture_semantic_hook_policy = true;
21869        let lease = install_managed_lease(&shared);
21870        let address = SessionAddress {
21871            workspace_id: lease.workspace_id.clone(),
21872            session: SessionKey {
21873                instance_id: AgentInstanceId(206),
21874                generation: SessionGeneration(1),
21875            },
21876        };
21877        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21878        let effective_runtime_policy =
21879            shared.effective_spawn_runtime_policy(pre_admission_policy);
21880        assert_ne!(effective_runtime_policy, pre_admission_policy);
21881        assert!(effective_runtime_policy.provider_session_identity);
21882
21883        let record_id = shared
21884            .bind_spawn_session(
21885                &address,
21886                agent("claude"),
21887                SessionMode::Pty,
21888                effective_runtime_policy,
21889                SpawnRecordPolicy::Always,
21890                None,
21891            )
21892            .unwrap()
21893            .unwrap();
21894        assert_eq!(
21895            shared.record(&record_id).unwrap().state,
21896            ManagedSessionState::IdentityPending,
21897        );
21898        let (record_holder, raw_inventory_record_id) = {
21899            let mut bindings = shared
21900                .session_bindings
21901                .lock()
21902                .unwrap_or_else(|poisoned| poisoned.into_inner());
21903            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21904            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21905            managed_spawn_record_ownership(binding, effective_runtime_policy)
21906        };
21907        assert_eq!(record_holder.as_ref(), Some(&record_id));
21908        assert!(raw_inventory_record_id.is_none());
21909        let snapshot = shared
21910            .managed_worktrees
21911            .lock()
21912            .unwrap_or_else(|poisoned| poisoned.into_inner())
21913            .bind_session(
21914                &lease.lease_id,
21915                ManagedWorktreeSessionHolder {
21916                    incarnation_id: shared.incarnation_id,
21917                    instance_id: address.session.instance_id,
21918                    generation: address.session.generation,
21919                },
21920                record_holder,
21921                unix_time_ms(),
21922            )
21923            .unwrap();
21924        assert_eq!(snapshot.managed_record_count, 1);
21925        assert_eq!(
21926            shared
21927                .managed_worktrees
21928                .lock()
21929                .unwrap_or_else(|poisoned| poisoned.into_inner())
21930                .get(&lease.lease_id)
21931                .unwrap()
21932                .record_holders,
21933            vec![record_id.clone()],
21934        );
21935
21936        let identity = ProviderSessionIdentity {
21937            key: ProviderSessionKey::SessionId,
21938            id: "monitoring-fixture-managed-session".to_owned(),
21939            transcript_path: None,
21940        };
21941        shared.publish_control(ControlEvent {
21942            sequence: 1,
21943            command_id: None,
21944            instance_id: address.session.instance_id,
21945            generation: address.session.generation,
21946            event: ControlEventKind::ProviderEvent {
21947                sequence: 1,
21948                source: gate4agent_types::ProviderSource {
21949                    family: AdapterFamily::Hook,
21950                    binding: AdapterBinding::new(
21951                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21952                        "fixture/v1",
21953                        gate4agent_types::AdapterVerification::SyntheticFixture,
21954                    )
21955                    .unwrap(),
21956                },
21957                source_sequence: 1,
21958                event: ProviderEvent::SessionIdentityObserved {
21959                    identity: identity.clone(),
21960                },
21961            },
21962        });
21963
21964        let live = shared.record(&record_id).unwrap();
21965        assert_eq!(live.state, ManagedSessionState::Live);
21966        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21967        assert_eq!(live.active_session.as_ref(), Some(&address));
21968        assert_eq!(
21969            shared
21970                .managed_worktrees
21971                .lock()
21972                .unwrap_or_else(|poisoned| poisoned.into_inner())
21973                .get(&lease.lease_id)
21974                .unwrap()
21975                .record_holders,
21976            vec![record_id],
21977        );
21978    }
21979
21980    fn managed_v2_test_shared(
21981        allocation_root: &Path,
21982        state_path: Option<PathBuf>,
21983        profile_revision: &str,
21984    ) -> NodeShared {
21985        let profile = ManagedWorktreeProfile::new(
21986            WorktreeProfileId::new("default").unwrap(),
21987            WorktreeProfileRevision::new(profile_revision).unwrap(),
21988            allocation_root,
21989            "gate4agent",
21990            "HEAD",
21991            ManagedWorktreeRetention::RemoveWhenReleased,
21992        )
21993        .unwrap();
21994        let workspace = WorkspaceConfig::new(
21995            WorkspaceId::new("primary").unwrap(),
21996            std::env::current_dir().unwrap(),
21997        )
21998        .unwrap()
21999        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22000        .with_managed_worktree_profile(profile)
22001        .unwrap();
22002        let catalog = active_registry().unwrap();
22003        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22004        let mut shared = NodeShared::new(
22005            handle,
22006            "fixture-token".to_owned(),
22007            NodeId::new("node-terminal-test").unwrap(),
22008            vec![workspace],
22009            vec![agent("claude")],
22010        );
22011        shared.state_path = state_path;
22012        shared
22013    }
22014
22015    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22016        let mut spawn_spec = spawn_spec_fixture();
22017        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22018        ManagedWorktreeSpawnRequestV2 {
22019            spawn_spec,
22020            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22021            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22022        }
22023    }
22024
22025    #[test]
22026    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22027        let shared = terminal_test_shared();
22028        let mut lease = install_managed_lease(&shared);
22029        let session = SessionAddress {
22030            workspace_id: lease.workspace_id.clone(),
22031            session: SessionKey {
22032                instance_id: AgentInstanceId(73),
22033                generation: SessionGeneration(4),
22034            },
22035        };
22036        lease.state = ManagedWorktreeLeaseState::InUse;
22037        lease.session_holders.push(ManagedWorktreeSessionHolder {
22038            incarnation_id: shared.incarnation_id,
22039            instance_id: session.session.instance_id,
22040            generation: session.session.generation,
22041        });
22042        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22043        assert!(resolved.target.worktree_id.is_none());
22044
22045        let receipt = managed_spawn_receipt(
22046            &resolved,
22047            shared.incarnation_id,
22048            session.clone(),
22049            lease.snapshot(),
22050            None,
22051            None,
22052            None,
22053        );
22054        assert!(resolved.target.worktree_id.is_none());
22055        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22056        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22057        assert_eq!(receipt.spawn.session, session);
22058        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22059        let encoded = serde_json::to_vec(&receipt).unwrap();
22060        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22061        assert_eq!(decoded, receipt);
22062    }
22063
22064    #[tokio::test]
22065    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22066        let root = temporary_workspace_root("managed-v2-revision");
22067        let allocation = root.join("allocation");
22068        std::fs::create_dir_all(&allocation).unwrap();
22069        let shared = managed_v2_test_shared(
22070            &allocation,
22071            Some(root.join("node-state.json")),
22072            "v1",
22073        );
22074        let mut request = managed_v2_request("managed-v2-revision");
22075        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22076
22077        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22078
22079        assert_eq!(
22080            failure.code,
22081            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22082        );
22083        assert!(shared
22084            .managed_worktrees
22085            .lock()
22086            .unwrap_or_else(|poisoned| poisoned.into_inner())
22087            .records()
22088            .is_empty());
22089        assert!(shared
22090            .managed_spawn_replays
22091            .lock()
22092            .unwrap_or_else(|poisoned| poisoned.into_inner())
22093            .is_empty());
22094        std::fs::remove_dir_all(root).unwrap();
22095    }
22096
22097    #[test]
22098    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22099        let root = temporary_workspace_root("managed-v2-concurrent");
22100        let allocation = root.join("allocation");
22101        std::fs::create_dir_all(&allocation).unwrap();
22102        let shared = Arc::new(managed_v2_test_shared(
22103            &allocation,
22104            Some(root.join("node-state.json")),
22105            "v1",
22106        ));
22107        let request = managed_v2_request("managed-v2-concurrent");
22108        let barrier = Arc::new(std::sync::Barrier::new(3));
22109        let mut joins = Vec::new();
22110        for _ in 0..2 {
22111            let shared = Arc::clone(&shared);
22112            let request = request.clone();
22113            let barrier = Arc::clone(&barrier);
22114            joins.push(std::thread::spawn(move || {
22115                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22116                barrier.wait();
22117                shared.acquire_durable_managed_spawn_v2(&request, digest)
22118            }));
22119        }
22120        barrier.wait();
22121        let results = joins
22122            .into_iter()
22123            .map(|join| join.join().unwrap())
22124            .collect::<Vec<_>>();
22125        assert_eq!(
22126            results
22127                .iter()
22128                .filter(|result| {
22129                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22130                })
22131                .count(),
22132            1,
22133        );
22134        assert_eq!(
22135            results
22136                .iter()
22137                .filter(|result| {
22138                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22139                })
22140                .count(),
22141            1,
22142        );
22143        assert_eq!(
22144            shared
22145                .managed_spawn_replays
22146                .lock()
22147                .unwrap_or_else(|poisoned| poisoned.into_inner())
22148                .len(),
22149            1,
22150        );
22151        assert!(shared
22152            .managed_worktrees
22153            .lock()
22154            .unwrap_or_else(|poisoned| poisoned.into_inner())
22155            .records()
22156            .is_empty());
22157        drop(shared);
22158        std::fs::remove_dir_all(root).unwrap();
22159    }
22160
22161    #[tokio::test]
22162    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22163        let root = temporary_workspace_root("managed-v2-linked-pending");
22164        let allocation = root.join("allocation");
22165        std::fs::create_dir_all(&allocation).unwrap();
22166        let state_path = root.join("node-state.json");
22167        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22168        let lease = install_managed_lease(&shared);
22169        let request = managed_v2_request("managed-v2-linked-pending");
22170        assert!(matches!(
22171            shared
22172                .acquire_durable_managed_spawn_v2(
22173                    &request,
22174                    managed_spawn_request_digest_v2(&request).unwrap(),
22175                )
22176                .unwrap(),
22177            DurableManagedSpawnReplayDecision::Reserved,
22178        ));
22179        {
22180            let _transaction = shared
22181                .state_transaction
22182                .lock()
22183                .unwrap_or_else(|poisoned| poisoned.into_inner());
22184            shared
22185                .link_durable_managed_spawn_v2_lease_locked(
22186                    &request.spawn_spec.idempotency_key,
22187                    &lease.lease_id,
22188                )
22189                .unwrap();
22190            shared.persist_state_locked().unwrap();
22191        }
22192        drop(shared);
22193
22194        let mut loaded = session_registry::load(
22195            Some(&state_path),
22196            &NodeId::new("node-terminal-test").unwrap(),
22197        )
22198        .unwrap();
22199        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22200        *reopened
22201            .managed_worktrees
22202            .lock()
22203            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22204            ManagedWorktreeRegistry::from_records(
22205                std::mem::take(&mut loaded.managed_worktrees),
22206                std::mem::take(&mut loaded.managed_worktree_tombstones),
22207            )
22208            .unwrap();
22209        *reopened
22210            .managed_spawn_replays
22211            .lock()
22212            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22213            .managed_spawn_replays
22214            .into_iter()
22215            .map(|record| (record.idempotency_key.clone(), record))
22216            .collect();
22217        let lease_count = reopened
22218            .managed_worktrees
22219            .lock()
22220            .unwrap_or_else(|poisoned| poisoned.into_inner())
22221            .records()
22222            .len();
22223
22224        let failure = reopened
22225            .spawn_managed_worktree_v2(request)
22226            .await
22227            .unwrap_err();
22228
22229        assert_eq!(
22230            failure.code,
22231            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22232        );
22233        assert_eq!(
22234            reopened
22235                .managed_worktrees
22236                .lock()
22237                .unwrap_or_else(|poisoned| poisoned.into_inner())
22238                .records()
22239                .len(),
22240            lease_count,
22241        );
22242        std::fs::remove_dir_all(root).unwrap();
22243    }
22244
22245    #[tokio::test]
22246    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22247        let root = temporary_workspace_root("managed-v2-reopen");
22248        let allocation = root.join("allocation");
22249        std::fs::create_dir_all(&allocation).unwrap();
22250        let state_path = root.join("node-state.json");
22251        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22252        let lease = install_managed_lease(&shared);
22253        let request = managed_v2_request("managed-v2-reopen");
22254        let session = SessionAddress {
22255            workspace_id: lease.workspace_id.clone(),
22256            session: SessionKey {
22257                instance_id: AgentInstanceId(91),
22258                generation: SessionGeneration(1),
22259            },
22260        };
22261        let snapshot = {
22262            let mut registry = shared
22263                .managed_worktrees
22264                .lock()
22265                .unwrap_or_else(|poisoned| poisoned.into_inner());
22266            let current = registry.get_mut(&lease.lease_id).unwrap();
22267            current.state = ManagedWorktreeLeaseState::InUse;
22268            current.session_holders.push(ManagedWorktreeSessionHolder {
22269                incarnation_id: shared.incarnation_id,
22270                instance_id: session.session.instance_id,
22271                generation: session.session.generation,
22272            });
22273            current.snapshot()
22274        };
22275        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22276        let receipt = managed_spawn_receipt(
22277            &resolved,
22278            shared.incarnation_id,
22279            session,
22280            snapshot,
22281            None,
22282            None,
22283            None,
22284        );
22285        assert!(matches!(
22286            shared
22287                .acquire_durable_managed_spawn_v2(
22288                    &request,
22289                    managed_spawn_request_digest_v2(&request).unwrap(),
22290                )
22291                .unwrap(),
22292            DurableManagedSpawnReplayDecision::Reserved,
22293        ));
22294        {
22295            let _transaction = shared
22296                .state_transaction
22297                .lock()
22298                .unwrap_or_else(|poisoned| poisoned.into_inner());
22299            shared
22300                .link_durable_managed_spawn_v2_lease_locked(
22301                    &request.spawn_spec.idempotency_key,
22302                    &lease.lease_id,
22303                )
22304                .unwrap();
22305            shared.persist_state_locked().unwrap();
22306        }
22307        shared
22308            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22309            .unwrap();
22310        let legacy_same_key = ManagedWorktreeSpawnRequest {
22311            spawn_spec: request.spawn_spec.clone(),
22312            worktree_profile_id: request.worktree_profile_id.clone(),
22313        };
22314        shared.remember_managed_spawn_attempt(
22315            Some(&request.spawn_spec.idempotency_key),
22316            legacy_same_key.clone(),
22317            Ok(receipt.clone()),
22318            Instant::now(),
22319        );
22320        assert!(shared
22321            .replay_managed_spawn(&legacy_same_key, Instant::now())
22322            .unwrap()
22323            .is_none());
22324        drop(shared);
22325
22326        let mut loaded = session_registry::load(
22327            Some(&state_path),
22328            &NodeId::new("node-terminal-test").unwrap(),
22329        )
22330        .unwrap();
22331        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22332        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22333        *reopened
22334            .managed_worktrees
22335            .lock()
22336            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22337            ManagedWorktreeRegistry::from_records(
22338                std::mem::take(&mut loaded.managed_worktrees),
22339                std::mem::take(&mut loaded.managed_worktree_tombstones),
22340            )
22341            .unwrap();
22342        *reopened
22343            .managed_spawn_replays
22344            .lock()
22345            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22346            .managed_spawn_replays
22347            .into_iter()
22348            .map(|record| (record.idempotency_key.clone(), record))
22349            .collect();
22350        let lease_count_before = reopened
22351            .managed_worktrees
22352            .lock()
22353            .unwrap_or_else(|poisoned| poisoned.into_inner())
22354            .records()
22355            .len();
22356
22357        assert_eq!(
22358            reopened
22359                .spawn_managed_worktree_v2(request.clone())
22360                .await
22361                .unwrap(),
22362            receipt,
22363        );
22364        assert_eq!(
22365            reopened
22366                .managed_worktrees
22367                .lock()
22368                .unwrap_or_else(|poisoned| poisoned.into_inner())
22369                .records()
22370                .len(),
22371            lease_count_before,
22372        );
22373        let mut changed = request;
22374        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22375        let failure = reopened
22376            .spawn_managed_worktree_v2(changed)
22377            .await
22378            .unwrap_err();
22379        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22380        assert_eq!(
22381            reopened
22382                .managed_worktrees
22383                .lock()
22384                .unwrap_or_else(|poisoned| poisoned.into_inner())
22385                .records()
22386                .len(),
22387            lease_count_before,
22388        );
22389        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22390        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22391        let failure = reopened
22392            .spawn_managed_worktree_v2(new_key)
22393            .await
22394            .unwrap_err();
22395        assert_eq!(
22396            failure.code,
22397            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22398        );
22399        assert_eq!(
22400            reopened
22401                .managed_worktrees
22402                .lock()
22403                .unwrap_or_else(|poisoned| poisoned.into_inner())
22404                .records()
22405                .len(),
22406            lease_count_before,
22407        );
22408        assert_eq!(
22409            reopened
22410                .managed_spawn_replays
22411                .lock()
22412                .unwrap_or_else(|poisoned| poisoned.into_inner())
22413                .len(),
22414            1,
22415        );
22416        std::fs::remove_dir_all(root).unwrap();
22417    }
22418
22419    #[test]
22420    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22421        let shared = terminal_test_shared();
22422        let request = ManagedWorktreeSpawnRequest {
22423            spawn_spec: spawn_spec_fixture(),
22424            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22425        };
22426        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22427        shared.remember_managed_spawn(
22428            request.clone(),
22429            Err(expected.clone()),
22430            Instant::now(),
22431        );
22432
22433        assert_eq!(
22434            shared
22435                .replay_managed_spawn(&request, Instant::now())
22436                .unwrap()
22437                .unwrap(),
22438            Err(expected),
22439        );
22440        assert!(shared
22441            .managed_spawn_replays
22442            .lock()
22443            .unwrap_or_else(|poisoned| poisoned.into_inner())
22444            .is_empty());
22445    }
22446
22447    #[tokio::test]
22448    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22449        let shared = terminal_test_shared();
22450        let lease = install_managed_lease(&shared);
22451        let raw = shared.spawn_session(
22452            lease.workspace_id.clone(),
22453            agent("claude"),
22454            SessionMode::Pty,
22455            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22456            None,
22457        ).await.unwrap_err();
22458        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22459        let mut spec = spawn_spec_fixture();
22460        spec.target.workspace_id = lease.workspace_id.clone();
22461        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22462        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22463        let mut selected = spawn_spec_fixture();
22464        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22465        selected.target.worktree_id = Some(lease.workspace_id);
22466        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22467        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22468        assert!(shared.handle.snapshot().sessions.is_empty());
22469    }
22470
22471    #[test]
22472    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22473        let shared = terminal_test_shared();
22474        let lease = install_managed_lease(&shared);
22475        let snapshot = snapshot_for_wire(
22476            &shared, true, true, true, false, true, true, false, false, true,
22477        );
22478        assert!(snapshot.managed_worktrees.is_empty());
22479        assert!(snapshot.workspaces.iter().all(|workspace| {
22480            workspace.managed_worktree_profiles.is_none()
22481        }));
22482        let mut reply = ResponseEnvelope {
22483            request_id: 1,
22484            result: Ok(NodeResponse::Resync {
22485                event_sequence: 1,
22486                oldest_available_sequence: 1,
22487                snapshot: shared.snapshot(),
22488                events: vec![NodeEventEnvelope {
22489                    sequence: 1,
22490                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22491                }],
22492            }),
22493        };
22494        project_response_without_managed_worktrees(&mut reply);
22495        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22496            panic!("projection changed the response kind");
22497        };
22498        assert!(snapshot.managed_worktrees.is_empty());
22499        assert!(events.is_empty());
22500        let mut git = git_snapshot_for_parser();
22501        git.branch = Some(lease.branch.clone());
22502        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22503            lease_id: lease.lease_id.clone(),
22504            source_workspace_id: lease.source_workspace_id.clone(),
22505            branch: lease.branch.clone(),
22506            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22507            active_session_count: 1,
22508            managed_record_count: 0,
22509        });
22510        let mut inspection_reply = ResponseEnvelope {
22511            request_id: 2,
22512            result: Ok(NodeResponse::WorkspaceInspected {
22513                inspection: WorkspaceInspection {
22514                    workspace_id: lease.workspace_id.clone(),
22515                    entries: Vec::new(),
22516                    tree_truncated: false,
22517                    git,
22518                    truncation: None,
22519                },
22520            }),
22521        };
22522        project_response_without_managed_worktrees(&mut inspection_reply);
22523        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22524            panic!("projection changed the inspection response kind");
22525        };
22526        assert!(inspection.git.managed_worktree.is_none());
22527        let failure = managed_git_worktree_failure(GitWorktreeError {
22528            kind: GitWorktreeErrorKind::Failed,
22529            message: r"git failed at C:\private\managed-target".to_owned(),
22530        });
22531        assert!(!failure.message.contains("private"));
22532        assert!(!failure.message.contains("managed-target"));
22533    }
22534
22535    #[tokio::test]
22536    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22537        let root = temporary_workspace_root("managed-restart");
22538        let source = root.join("source");
22539        let allocation = root.join("allocation");
22540        std::fs::create_dir_all(&source).unwrap();
22541        std::fs::create_dir_all(&allocation).unwrap();
22542        run_test_git(&source, &["init"]);
22543        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22544        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22545        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22546        run_test_git(&source, &["add", "seed.txt"]);
22547        run_test_git(&source, &["commit", "-m", "seed"]);
22548        let profile = ManagedWorktreeProfile::new(
22549            WorktreeProfileId::new("default").unwrap(),
22550            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22551            &allocation,
22552            "gate4agent",
22553            "HEAD",
22554            ManagedWorktreeRetention::Retain,
22555        ).unwrap();
22556        let workspace = WorkspaceConfig::new(
22557            WorkspaceId::new("primary").unwrap(),
22558            &source,
22559        ).unwrap()
22560            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22561            .with_managed_worktree_profile(profile.clone()).unwrap();
22562        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22563        let source_text = workspace.canonical_root().to_owned();
22564        let target_text = target.to_string_lossy().into_owned();
22565        let created = create_git_worktree(
22566            &source_text,
22567            &target_text,
22568            "gate4agent/mw-restart",
22569            Some("HEAD"),
22570        ).await.unwrap();
22571        let lease = ManagedWorktreeLeaseRecord {
22572            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22573            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22574            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22575            profile_id: profile.profile_id().clone(),
22576            profile_revision: profile.revision().clone(),
22577            target_root: created.path.clone(),
22578            branch: "gate4agent/mw-restart".to_owned(),
22579            base_commit: created.head.clone(),
22580            expected_head: None,
22581            retention: ManagedWorktreeRetention::Retain,
22582            state: ManagedWorktreeLeaseState::Allocating,
22583            session_holders: Vec::new(),
22584            record_holders: Vec::new(),
22585            cleanup_failure: None,
22586            created_at_unix_ms: 1,
22587            updated_at_unix_ms: 1,
22588        };
22589        let catalog = active_registry().unwrap();
22590        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22591        let shared = NodeShared::new_with_incarnation(
22592            handle,
22593            "fixture-token".to_owned(),
22594            NodeId::new("node-managed-restart").unwrap(),
22595            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22596            vec![workspace],
22597            vec![agent("claude")],
22598            ProviderRuntimeStatuses::default(),
22599            None,
22600            Vec::new(),
22601            Vec::new(),
22602            SpawnProfileRegistry::default(),
22603            None,
22604            None,
22605            None,
22606            None,
22607            Vec::new(),
22608            vec![lease.clone()],
22609            Vec::new(),
22610            Vec::new(),
22611            None,
22612            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22613        );
22614        shared.reconcile_managed_worktrees().await;
22615        let retained = shared.managed_worktrees.lock()
22616            .unwrap_or_else(|poisoned| poisoned.into_inner())
22617            .get(&lease.lease_id).unwrap().clone();
22618        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22619        assert!(shared.workspaces.read()
22620            .unwrap_or_else(|poisoned| poisoned.into_inner())
22621            .contains_key(&lease.workspace_id));
22622        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22623        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22624        assert!(!Path::new(&created.path).exists());
22625        std::fs::remove_dir_all(root).unwrap();
22626    }
22627
22628    fn run_test_git(root: &Path, arguments: &[&str]) {
22629        let output = std::process::Command::new("git")
22630            .arg("-C").arg(root).args(arguments).output().unwrap();
22631        assert!(
22632            output.status.success(),
22633            "git {:?} failed: {}",
22634            arguments,
22635            String::from_utf8_lossy(&output.stderr),
22636        );
22637    }
22638
22639    #[tokio::test]
22640    async fn spawn_spec_validation_fails_before_session_mutation() {
22641        let shared = terminal_test_shared();
22642        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22643            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22644        }));
22645        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22646            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22647        }));
22648        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22649            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22650        }));
22651
22652        let mut target_mismatch = spawn_spec_fixture();
22653        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22654        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22655        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22656
22657        let mut unknown_profile = spawn_spec_fixture();
22658        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22659        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22660        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22661        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22662
22663        let mut revision_mismatch = spawn_spec_fixture();
22664        revision_mismatch.idempotency_key =
22665            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22666        revision_mismatch.expected_profile_revision =
22667            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22668        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22669        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22670
22671        let mut unsupported_materializer = spawn_spec_fixture();
22672        unsupported_materializer.idempotency_key =
22673            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22674        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22675            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22676        };
22677        let failure = shared
22678            .spawn_from_spec(unsupported_materializer)
22679            .await
22680            .unwrap_err();
22681        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22682
22683        let failure = shared
22684            .spawn_session_with_deadline(
22685                WorkspaceId::new("primary").unwrap(),
22686                agent("claude"),
22687                SessionMode::Pty,
22688                gate4agent_types::TerminalSize {
22689                    rows: 24,
22690                    columns: 80,
22691                },
22692                None,
22693                None,
22694                None,
22695                None,
22696                None,
22697                None,
22698                SpawnRecordPolicy::ProviderIdentityOnly,
22699                Some(Instant::now()),
22700                &[],
22701                None,
22702                ApprovalLevel::default(),
22703                None,
22704                None,
22705            )
22706            .await
22707            .unwrap_err();
22708        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22709        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22710        assert!(shared.handle.snapshot().sessions.is_empty());
22711    }
22712
22713    #[test]
22714    fn worktree_selection_requires_its_separate_negotiated_capability() {
22715        let mut spec = spawn_spec_fixture();
22716        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22717        let request = NodeRequest::SpawnSpec { spec };
22718        let spawn_spec_capability =
22719            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22720        assert!(request_uses_unnegotiated_capability(
22721            &request,
22722            &[spawn_spec_capability.clone()],
22723        ));
22724        let worktree_capability =
22725            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22726        assert!(request_uses_unnegotiated_capability(
22727            &request,
22728            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22729        ));
22730        let profile_revision_capability =
22731            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22732        assert!(request_uses_unnegotiated_capability(
22733            &request,
22734            &[
22735                spawn_spec_capability.clone(),
22736                worktree_capability.clone(),
22737                profile_revision_capability.clone(),
22738            ],
22739        ));
22740        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22741        // `Inherit`, not `Clear` — the profile's own defaults might still
22742        // materialize a bundle or a context pack, so both capabilities stay
22743        // conservatively required until negotiated (see
22744        // `requires_session_bundle_materialization_capability` /
22745        // `requires_history_context_pack_capability`).
22746        let bundle_materialization_capability =
22747            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22748        assert!(request_uses_unnegotiated_capability(
22749            &request,
22750            &[
22751                spawn_spec_capability.clone(),
22752                worktree_capability.clone(),
22753                profile_revision_capability.clone(),
22754                bundle_materialization_capability.clone(),
22755            ],
22756        ));
22757        let history_context_pack_capability =
22758            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22759        assert!(!request_uses_unnegotiated_capability(
22760            &request,
22761            &[
22762                spawn_spec_capability,
22763                worktree_capability,
22764                profile_revision_capability,
22765                bundle_materialization_capability,
22766                history_context_pack_capability,
22767            ],
22768        ));
22769    }
22770
22771    #[tokio::test]
22772    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22773        let mut shared = terminal_test_shared();
22774        let spec = spawn_spec_fixture();
22775        let receipt = shared
22776            .resolve_spawn_spec(&spec)
22777            .unwrap()
22778            .receipt(shared.incarnation_id, terminal_address(1));
22779        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22780
22781        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22782        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22783        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22784
22785        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22786
22787        let mut revision_conflicting = spec.clone();
22788        revision_conflicting.expected_profile_revision =
22789            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22790        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22791        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22792
22793        let mut stale_cache_miss = spec.clone();
22794        stale_cache_miss.idempotency_key =
22795            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22796        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22797        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22798
22799        let mut conflicting = spec;
22800        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22801        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22802        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22803        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22804        assert!(shared.handle.snapshot().sessions.is_empty());
22805    }
22806
22807    #[tokio::test]
22808    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22809        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22810        let mut spec = spawn_spec_fixture();
22811        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22812        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22813        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22814
22815        let failure = shared
22816            .create_worktree(
22817                WorkspaceId::new("primary").unwrap(),
22818                WorkspaceId::new("new-worktree").unwrap(),
22819                "not-an-absolute-path".to_owned(),
22820                "invalid branch".to_owned(),
22821                None,
22822            )
22823            .await
22824            .unwrap_err();
22825        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22826
22827        let failure = shared
22828            .remove_worktree(
22829                WorkspaceId::new("primary").unwrap(),
22830                "not-an-absolute-path".to_owned(),
22831            )
22832            .await
22833            .unwrap_err();
22834        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22835        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22836        assert!(shared.handle.snapshot().sessions.is_empty());
22837    }
22838
22839    #[tokio::test]
22840    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22841        let root = temporary_workspace_root("manual-worktree-compensation");
22842        let source = root.join("source");
22843        let target = root.join("target");
22844        let invalid_state_parent = root.join("state-parent-is-a-file");
22845        let invalid_state_path = invalid_state_parent.join("state.json");
22846        std::fs::create_dir_all(&source).unwrap();
22847        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22848        run_test_git(&source, &["init"]);
22849        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22850        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22851        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22852        run_test_git(&source, &["add", "seed.txt"]);
22853        run_test_git(&source, &["commit", "-m", "seed"]);
22854
22855        let catalog = active_registry().unwrap();
22856        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22857        let workspace = WorkspaceConfig::new(
22858            WorkspaceId::new("primary").unwrap(),
22859            &source,
22860        ).unwrap();
22861        let mut shared = NodeShared::new(
22862            handle,
22863            "fixture-token".to_owned(),
22864            NodeId::new("node-worktree-compensation").unwrap(),
22865            vec![workspace],
22866            vec![agent("claude")],
22867        );
22868        shared.state_path = Some(invalid_state_path);
22869
22870        let error = shared.create_worktree(
22871            WorkspaceId::new("primary").unwrap(),
22872            WorkspaceId::new("compensated").unwrap(),
22873            target.to_string_lossy().into_owned(),
22874            "gate4agent/compensated".to_owned(),
22875            Some("HEAD".to_owned()),
22876        ).await.unwrap_err();
22877
22878        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22879        assert!(!target.exists());
22880        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22881            .iter()
22882            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22883        std::fs::remove_dir_all(root).unwrap();
22884    }
22885
22886    #[test]
22887    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22888        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22889            NativeGitWorktreeSnapshot {
22890                path: path.to_owned(),
22891                head: "0123456789abcdef".to_owned(),
22892                branch: Some("refs/heads/test".to_owned()),
22893                is_bare,
22894                is_main,
22895                locked: false,
22896                lock_reason: None,
22897                prunable: false,
22898                prunable_reason: None,
22899                workspace_id: None,
22900            }
22901        }
22902
22903        let source = r"C:\repo";
22904        let selected = r"C:\repo-worktree";
22905        let listed = vec![
22906            worktree(source, true, false),
22907            worktree(selected, false, false),
22908        ];
22909        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22910
22911        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22912        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22913
22914        let bare = vec![
22915            worktree(source, true, false),
22916            worktree(selected, false, true),
22917        ];
22918        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22919        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22920
22921        let foreign_failure =
22922            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22923        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22924    }
22925
22926    fn terminal_frame(sequence: u64) -> TerminalFrame {
22927        TerminalFrame {
22928            sequence,
22929            size: gate4agent_types::TerminalSize {
22930                rows: 24,
22931                columns: 80,
22932            },
22933            cursor_row: 2,
22934            cursor_column: 3,
22935            contents: format!("frame-{sequence}"),
22936            formatted: vec![1, 2, 3],
22937            scrollback_formatted: Vec::new(),
22938            alternate_screen: false,
22939            mouse_protocol_enabled: false,
22940            mouse_protocol_encoding: Default::default(),
22941            produced_at_unix_ms: 0,
22942            screen_state: gate4agent_types::PtyScreenState::default(),
22943            bracketed_paste: None,
22944        }
22945    }
22946
22947    #[test]
22948    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22949        let shared = terminal_test_shared();
22950        let mut receiver = shared.terminal_event_tx.subscribe();
22951        let original = terminal_address(1);
22952
22953        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22954        let first = receiver.try_recv().unwrap();
22955        assert_eq!(first.len(), 1);
22956        assert!(matches!(
22957            &first[0].event,
22958            NodeEvent::TerminalFrame { address, frame }
22959                if address == &original && frame.sequence == 7
22960        ));
22961
22962        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22963        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22964        assert!(matches!(
22965            receiver.try_recv(),
22966            Err(broadcast::error::TryRecvError::Empty)
22967        ));
22968
22969        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22970        let advanced = receiver.try_recv().unwrap();
22971        assert_eq!(advanced[0].sequence, 2);
22972
22973        let resumed = terminal_address(2);
22974        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22975        let reset = receiver.try_recv().unwrap();
22976        assert_eq!(reset[0].sequence, 3);
22977        assert!(matches!(
22978            &reset[0].event,
22979            NodeEvent::TerminalFrame { address, frame }
22980                if address == &resumed && frame.sequence == 1
22981        ));
22982        shared.clear_terminal_frame_watermark(&resumed);
22983        assert!(!shared
22984            .terminal_frame_watermarks
22985            .lock()
22986            .unwrap_or_else(|poisoned| poisoned.into_inner())
22987            .contains_key(&resumed.session.instance_id));
22988    }
22989
22990    #[test]
22991    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22992        let shared = terminal_test_shared();
22993        let mut receiver = shared.terminal_event_tx.subscribe();
22994        let address = terminal_address(1);
22995        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22996
22997        for sequence in 1..=count {
22998            shared.publish_terminal_frame_candidates(vec![(
22999                address.clone(),
23000                terminal_frame(sequence),
23001            )]);
23002        }
23003
23004        let history = shared
23005            .history
23006            .lock()
23007            .unwrap_or_else(|poisoned| poisoned.into_inner());
23008        assert!(history.events.is_empty());
23009        assert_eq!(history.last_sequence, count);
23010        assert_eq!(history.replay_floor_sequence, 1);
23011        drop(history);
23012        let NodeResponse::Resync {
23013            event_sequence,
23014            oldest_available_sequence,
23015            events,
23016            ..
23017        } = shared.resync(0) else {
23018            unreachable!("resync helper returned another response")
23019        };
23020        assert_eq!(event_sequence, count);
23021        assert_eq!(oldest_available_sequence, 1);
23022        assert!(events.is_empty());
23023        assert!(matches!(
23024            resync_required_event(&shared).event,
23025            NodeEvent::ResyncRequired {
23026                oldest_available_sequence: 1,
23027            }
23028        ));
23029        assert!(matches!(
23030            receiver.try_recv(),
23031            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23032        ));
23033        let latest = receiver.try_recv().unwrap();
23034        assert_eq!(latest.len(), 1);
23035        assert_eq!(latest[0].sequence, count);
23036    }
23037
23038    #[test]
23039    fn real_durable_eviction_advances_authoritative_replay_floor() {
23040        let shared = terminal_test_shared();
23041        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23042            shared.publish(NodeEvent::ControllerChanged { controller: None });
23043        }
23044
23045        let NodeResponse::Resync {
23046            event_sequence,
23047            oldest_available_sequence,
23048            events,
23049            ..
23050        } = shared.resync(0) else {
23051            unreachable!("resync helper returned another response")
23052        };
23053        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23054        assert_eq!(oldest_available_sequence, 2);
23055        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23056        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23057        assert!(matches!(
23058            resync_required_event(&shared).event,
23059            NodeEvent::ResyncRequired {
23060                oldest_available_sequence: 2,
23061            }
23062        ));
23063    }
23064
23065    #[test]
23066    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23067        let shared = terminal_test_shared();
23068        let without_capability = terminal_event_subscription(&shared, false);
23069        let with_capability = terminal_event_subscription(&shared, true);
23070        assert!(without_capability.is_none());
23071        assert!(with_capability.is_some());
23072        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23073            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23074        }));
23075    }
23076
23077    #[test]
23078    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23079        let mut pending = Vec::new();
23080        let mut event_sequence = 0_u64;
23081        for frame_sequence in 1..=4 {
23082            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23083                event_sequence += 1;
23084                let mut address = terminal_address(1);
23085                address.session.instance_id = AgentInstanceId(instance);
23086                queue_connection_event(
23087                    &mut pending,
23088                    NodeEventEnvelope {
23089                        sequence: event_sequence,
23090                        event: NodeEvent::TerminalFrame {
23091                            address,
23092                            frame: terminal_frame(frame_sequence),
23093                        },
23094                    },
23095                    0,
23096                );
23097            }
23098        }
23099        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23100        pending.sort_unstable_by_key(|event| event.sequence);
23101
23102        let mut uninterrupted = pending.clone();
23103        let first_burst = connection_event_burst_len(uninterrupted.len());
23104        uninterrupted.drain(..first_burst);
23105        let second_burst = connection_event_burst_len(uninterrupted.len());
23106        uninterrupted.drain(..second_burst);
23107        assert!(uninterrupted.is_empty());
23108
23109        let (frame_tx, mut frame_rx) =
23110            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23111        frame_tx
23112            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23113                request_id: 77,
23114                request: NodeRequest::Snapshot,
23115            })))
23116            .unwrap();
23117        let burst_len = connection_event_burst_len(pending.len());
23118        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23119        pending.drain(..burst_len);
23120        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23121        assert!(matches!(
23122            frame_rx.try_recv(),
23123            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23124                request_id: 77,
23125                request: NodeRequest::Snapshot,
23126            })))
23127        ));
23128    }
23129
23130    #[test]
23131    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23132        let mut pending = Vec::new();
23133        for sequence in [7_u64, 8] {
23134            let mut address = terminal_address(1);
23135            address.session.instance_id = AgentInstanceId(sequence);
23136            queue_connection_event(
23137                &mut pending,
23138                NodeEventEnvelope {
23139                    sequence,
23140                    event: NodeEvent::TerminalFrame {
23141                        address,
23142                        frame: terminal_frame(sequence),
23143                    },
23144                },
23145                0,
23146            );
23147        }
23148        let mut discard_through = 0;
23149        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23150        assert!(pending.is_empty());
23151
23152        let replayed = NodeEventEnvelope {
23153            sequence: 8,
23154            event: NodeEvent::TerminalFrame {
23155                address: terminal_address(1),
23156                frame: terminal_frame(8),
23157            },
23158        };
23159        let advanced = NodeEventEnvelope {
23160            sequence: 9,
23161            event: NodeEvent::TerminalFrame {
23162                address: terminal_address(2),
23163                frame: terminal_frame(1),
23164            },
23165        };
23166        queue_connection_event_batch(
23167            &mut pending,
23168            &[replayed, advanced.clone()],
23169            discard_through,
23170        );
23171        assert_eq!(pending, vec![advanced]);
23172    }
23173
23174    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23175        ManagedSessionRecord {
23176            record_id: SessionRecordId::new(record_id).unwrap(),
23177            display_name: format!("{provider} record"),
23178            provider: agent(provider),
23179            mode: SessionMode::Pty,
23180            state: ManagedSessionState::Unavailable,
23181            workspace_id: WorkspaceId::new("primary").unwrap(),
23182            canonical_root: opaque_windows_path(
23183                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23184            ),
23185            provider_session: None,
23186            active_session: None,
23187            environment_profile: None,
23188            bundle: None,
23189            context_id: None,
23190            context: None,
23191            exported_context: None,
23192            task_binding: None,
23193            created_at_unix_ms: 1,
23194            updated_at_unix_ms: 1,
23195            last_error: None,
23196        }
23197    }
23198
23199    fn retention_test_record(
23200        workspace_id: &str,
23201        record_id: &str,
23202        state: ManagedSessionState,
23203        updated_at_unix_ms: u64,
23204    ) -> ManagedSessionRecord {
23205        ManagedSessionRecord {
23206            record_id: SessionRecordId::new(record_id).unwrap(),
23207            display_name: format!("{record_id} display"),
23208            provider: agent("claude"),
23209            mode: SessionMode::Pty,
23210            state,
23211            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23212            canonical_root: opaque_windows_path(
23213                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23214            ),
23215            provider_session: None,
23216            active_session: None,
23217            environment_profile: None,
23218            bundle: None,
23219            context_id: None,
23220            context: None,
23221            exported_context: None,
23222            task_binding: None,
23223            created_at_unix_ms: updated_at_unix_ms,
23224            updated_at_unix_ms,
23225            last_error: None,
23226        }
23227    }
23228
23229    /// Two workspaces of three `Unavailable` records each, staggered by
23230    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23231    /// `workspace-a` (all as old as that workspace's oldest real record --
23232    /// if the sweep's state gate ever leaked, these would be the first
23233    /// thing it wrongly retired). Ages, oldest to newest:
23234    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23235    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23236    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23237        vec![
23238            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23239            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23240            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23241            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23242            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23243            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23244            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23245            retention_test_record(
23246                "workspace-a",
23247                "decoy-pending",
23248                ManagedSessionState::IdentityPending,
23249                now - 500_000,
23250            ),
23251            retention_test_record(
23252                "workspace-a",
23253                "decoy-dormant",
23254                ManagedSessionState::Dormant,
23255                now - 500_000,
23256            ),
23257        ]
23258    }
23259
23260    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23261        let root = temporary_workspace_root("session-record-retention");
23262        std::fs::create_dir_all(&root).unwrap();
23263        let catalog = active_registry().unwrap();
23264        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23265        let workspace_a =
23266            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23267        let workspace_b =
23268            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23269        let mut shared = NodeShared::new(
23270            handle,
23271            "fixture-token".to_owned(),
23272            NodeId::new("node-retention-test").unwrap(),
23273            vec![workspace_a, workspace_b],
23274            vec![agent("claude")],
23275        );
23276        shared.state_path = Some(root.join("node-state-v10.json"));
23277        let now = unix_time_ms();
23278        for record in retention_fixture_records(now) {
23279            shared.insert_record(record).unwrap();
23280        }
23281        shared
23282            .persist_state_locked()
23283            .expect("fixture-only, empty-history persist against a fresh temp state path");
23284        (shared, runtime, root, now)
23285    }
23286
23287    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23288        shared
23289            .history
23290            .lock()
23291            .unwrap_or_else(|poisoned| poisoned.into_inner())
23292            .events
23293            .iter()
23294            .filter_map(|envelope| match &envelope.event {
23295                NodeEvent::SessionRecordRemoved { record_id } => {
23296                    Some(record_id.as_str().to_owned())
23297                }
23298                _ => None,
23299            })
23300            .collect()
23301    }
23302
23303    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23304        shared
23305            .session_records
23306            .lock()
23307            .unwrap_or_else(|poisoned| poisoned.into_inner())
23308            .records
23309            .keys()
23310            .map(|record_id| record_id.as_str().to_owned())
23311            .collect()
23312    }
23313
23314    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23315        values.iter().map(|value| (*value).to_owned()).collect()
23316    }
23317
23318    #[tokio::test]
23319    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23320        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23321        shared.session_record_retention = SessionRecordRetentionConfig {
23322            age_ms: 0,
23323            keep_per_workspace: 1,
23324        };
23325        shared.retire_unavailable_session_records().await;
23326
23327        assert_eq!(
23328            retention_remaining_record_ids(&shared),
23329            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23330        );
23331        assert_eq!(
23332            retention_removed_record_ids(&shared),
23333            owned(&["a1", "a2", "b1", "b2"]),
23334        );
23335        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23336
23337        drop(shared);
23338        drop(runtime);
23339        std::fs::remove_dir_all(root).unwrap();
23340    }
23341
23342    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23343    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23344    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23345    /// 200s old against a 250s threshold) even though it fails the keep-N
23346    /// test too, proving retirement needs BOTH, not either.
23347    #[tokio::test]
23348    async fn session_record_retention_both_set_retires_only_the_intersection() {
23349        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23350        shared.session_record_retention = SessionRecordRetentionConfig {
23351            age_ms: 250_000,
23352            keep_per_workspace: 1,
23353        };
23354        shared.retire_unavailable_session_records().await;
23355
23356        assert_eq!(
23357            retention_remaining_record_ids(&shared),
23358            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23359        );
23360        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23361        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23362
23363        drop(shared);
23364        drop(runtime);
23365        std::fs::remove_dir_all(root).unwrap();
23366    }
23367
23368    #[tokio::test]
23369    async fn session_record_retention_both_disabled_is_a_no_op() {
23370        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23371        shared.session_record_retention = SessionRecordRetentionConfig::default();
23372        shared.retire_unavailable_session_records().await;
23373
23374        assert_eq!(
23375            retention_remaining_record_ids(&shared),
23376            owned(&[
23377                "a1", "a2", "a3", "b1", "b2", "b3",
23378                "decoy-live", "decoy-pending", "decoy-dormant",
23379            ]),
23380        );
23381        assert!(retention_removed_record_ids(&shared).is_empty());
23382        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23383
23384        drop(shared);
23385        drop(runtime);
23386        std::fs::remove_dir_all(root).unwrap();
23387    }
23388
23389    #[test]
23390    fn session_record_context_export_selects_one_exact_history_identity() {
23391        let candidates = vec![
23392            HistoryCandidateSummary {
23393                id: "candidate-other".to_owned(),
23394                session_id_hint: "provider-other".to_owned(),
23395                modified_at_unix_ms: Some(1),
23396            },
23397            HistoryCandidateSummary {
23398                id: "candidate-exact".to_owned(),
23399                session_id_hint: "provider-exact".to_owned(),
23400                modified_at_unix_ms: Some(2),
23401            },
23402        ];
23403        assert_eq!(
23404            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23405            "candidate-exact",
23406        );
23407    }
23408
23409    #[test]
23410    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23411        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23412            id: id.to_owned(),
23413            session_id_hint: hint.to_owned(),
23414            modified_at_unix_ms: None,
23415        };
23416        let missing = select_history_candidate(
23417            &[candidate("candidate-other", "provider-other")],
23418            Some("provider-exact"),
23419        )
23420        .unwrap_err();
23421        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23422        let ambiguous = select_history_candidate(
23423            &[
23424                candidate("candidate-a", "provider-exact"),
23425                candidate("candidate-b", "provider-exact"),
23426            ],
23427            Some("provider-exact"),
23428        )
23429        .unwrap_err();
23430        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23431    }
23432
23433    /// The PTY-sidecar-observation shape (no fleet provider today, but
23434    /// capability-keyed rather than vendor-locked) never carries a verified
23435    /// `provider_session`; its record-bound export instead falls back to
23436    /// "exactly one discovered candidate" (see
23437    /// `export_context_pack_for_session_record_inner`). This is the same
23438    /// ambiguity refusal as the identity-filtered path, just without an
23439    /// identity to filter by.
23440    #[test]
23441    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23442        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23443            id: id.to_owned(),
23444            session_id_hint: hint.to_owned(),
23445            modified_at_unix_ms: None,
23446        };
23447        assert_eq!(
23448            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23449                .unwrap(),
23450            "candidate-sole",
23451        );
23452        let missing = select_history_candidate(&[], None).unwrap_err();
23453        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23454        let ambiguous = select_history_candidate(
23455            &[
23456                candidate("candidate-a", "sidecar-session-a"),
23457                candidate("candidate-b", "sidecar-session-b"),
23458            ],
23459            None,
23460        )
23461        .unwrap_err();
23462        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23463    }
23464
23465    #[test]
23466    fn session_record_context_export_revalidation_rejects_record_change() {
23467        let session = terminal_address(1);
23468        let identity = ProviderSessionIdentity {
23469            key: ProviderSessionKey::SessionId,
23470            id: "provider-exact".to_owned(),
23471            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23472        };
23473        let mut expected = record("claude", "record-context-export");
23474        expected.state = ManagedSessionState::Live;
23475        expected.provider_session = Some(identity.clone());
23476        expected.active_session = Some(session.clone());
23477        let binding = SessionBinding {
23478            workspace_id: session.workspace_id.clone(),
23479            generation: session.session.generation,
23480            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23481            pending_resume: None,
23482            record_id: Some(expected.record_id.clone()),
23483            managed_worktree_lease_id: None,
23484            environment_profile: None,
23485            bundle: None,
23486            context: None,
23487            materialization_id: None,
23488        };
23489        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23490        assert!(session_record_context_export_binding_is_exact(
23491            &expected,
23492            &expected,
23493            Some(&identity),
23494            &session,
23495            &binding,
23496            &expected.provider,
23497            &current_root,
23498            false,
23499        ));
23500        let mut changed = expected.clone();
23501        changed.updated_at_unix_ms += 1;
23502        assert!(!session_record_context_export_binding_is_exact(
23503            &expected,
23504            &changed,
23505            Some(&identity),
23506            &session,
23507            &binding,
23508            &expected.provider,
23509            &current_root,
23510            false,
23511        ));
23512        // A same-instant timestamp bump alone (state and active_session
23513        // otherwise unchanged) is not the clean-detachment transition
23514        // either, even with the reactive flag on: only the exact Live ->
23515        // Dormant + active_session cleared shape qualifies.
23516        assert!(!session_record_context_export_binding_is_exact(
23517            &expected,
23518            &changed,
23519            Some(&identity),
23520            &session,
23521            &binding,
23522            &expected.provider,
23523            &current_root,
23524            true,
23525        ));
23526        let mut detached = expected.clone();
23527        detached.state = ManagedSessionState::Dormant;
23528        detached.active_session = None;
23529        detached.updated_at_unix_ms += 1;
23530        assert!(!session_record_context_export_binding_is_exact(
23531            &expected,
23532            &detached,
23533            Some(&identity),
23534            &session,
23535            &binding,
23536            &expected.provider,
23537            &current_root,
23538            false,
23539        ));
23540        assert!(session_record_context_export_binding_is_exact(
23541            &expected,
23542            &detached,
23543            Some(&identity),
23544            &session,
23545            &binding,
23546            &expected.provider,
23547            &current_root,
23548            true,
23549        ));
23550    }
23551
23552    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23553    /// for a record whose provider never carries a verified identity (the
23554    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23555    /// declares one today): `identity` is `None` throughout, and the
23556    /// exactness check falls back to comparing `provider_session` itself,
23557    /// which must still be `None` on both sides.
23558    #[test]
23559    fn session_record_context_export_binding_is_exact_without_identity() {
23560        let session = terminal_address(1);
23561        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23562        expected.state = ManagedSessionState::Live;
23563        expected.active_session = Some(session.clone());
23564        let binding = SessionBinding {
23565            workspace_id: session.workspace_id.clone(),
23566            generation: session.session.generation,
23567            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23568            pending_resume: None,
23569            record_id: Some(expected.record_id.clone()),
23570            managed_worktree_lease_id: None,
23571            environment_profile: None,
23572            bundle: None,
23573            context: None,
23574            materialization_id: None,
23575        };
23576        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23577        assert!(session_record_context_export_binding_is_exact(
23578            &expected,
23579            &expected,
23580            None,
23581            &session,
23582            &binding,
23583            &expected.provider,
23584            &current_root,
23585            false,
23586        ));
23587        // A record that gained an identity between the initial lookup and
23588        // this recheck no longer matches the `None` this shape expects to
23589        // keep seeing -- treated the same as any other mid-export drift.
23590        let mut drifted = expected.clone();
23591        drifted.provider_session = Some(ProviderSessionIdentity {
23592            key: ProviderSessionKey::SessionId,
23593            id: "unexpected-identity".to_owned(),
23594            transcript_path: None,
23595        });
23596        assert!(!session_record_context_export_binding_is_exact(
23597            &expected,
23598            &drifted,
23599            None,
23600            &session,
23601            &binding,
23602            &expected.provider,
23603            &current_root,
23604            false,
23605        ));
23606    }
23607
23608    #[test]
23609    fn session_record_context_export_preflight_requires_live_usable_source() {
23610        let mut source = record("claude", "record-context-export-preflight");
23611        source.state = ManagedSessionState::Live;
23612        assert!(session_record_context_export_source_is_usable(
23613            &source,
23614            &SessionStatus::Running,
23615            false,
23616        ));
23617        assert!(session_record_context_export_source_is_usable(
23618            &source,
23619            &SessionStatus::Exited { exit_code: Some(0) },
23620            false,
23621        ));
23622
23623        source.state = ManagedSessionState::Dormant;
23624        assert!(!session_record_context_export_source_is_usable(
23625            &source,
23626            &SessionStatus::Running,
23627            false,
23628        ));
23629        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23630        // the only caller that may treat a Dormant, cleanly-exited record as
23631        // still usable — the on-demand path above never does.
23632        assert!(session_record_context_export_source_is_usable(
23633            &source,
23634            &SessionStatus::Exited { exit_code: Some(0) },
23635            true,
23636        ));
23637        assert!(!session_record_context_export_source_is_usable(
23638            &source,
23639            &SessionStatus::Exited { exit_code: Some(0) },
23640            false,
23641        ));
23642        source.state = ManagedSessionState::Live;
23643        assert!(!session_record_context_export_source_is_usable(
23644            &source,
23645            &SessionStatus::Starting,
23646            false,
23647        ));
23648    }
23649
23650    #[test]
23651    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23652        let catalog = active_registry().unwrap();
23653        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23654        let workspace = WorkspaceConfig::new(
23655            WorkspaceId::new("primary").unwrap(),
23656            std::env::current_dir().unwrap(),
23657        )
23658        .unwrap();
23659        let shared = NodeShared::new(
23660            handle,
23661            "fixture-token".to_owned(),
23662            NodeId::new("node-context-export-conflict").unwrap(),
23663            vec![workspace],
23664            vec![agent("claude")],
23665        );
23666        let history = HistorySessionRecord {
23667            session_id: "provider-exact".to_owned(),
23668            title: None,
23669            cwd: None,
23670            model: None,
23671            message_count: 1,
23672            completed_turn_count: None,
23673            total_tokens: 0,
23674            messages: vec![gate4agent_types::HistoryMessageRecord {
23675                role: gate4agent_types::HistoryMessageRole::User,
23676                text: "bounded context".to_owned(),
23677            }],
23678        };
23679        let pack = NodeContextPack::export(
23680            ContextPackLineageReceipt {
23681                source_node_id: shared.node_id.clone(),
23682                source_session: terminal_address(1),
23683                source_provider: agent("claude"),
23684            },
23685            &history,
23686        )
23687        .unwrap();
23688        let context_id = pack.receipt().id.clone();
23689        let session = terminal_address(1);
23690        let identity = ProviderSessionIdentity {
23691            key: ProviderSessionKey::SessionId,
23692            id: history.session_id.clone(),
23693            transcript_path: None,
23694        };
23695        let mut expected = record("claude", "record-context-export-final-conflict");
23696        expected.state = ManagedSessionState::Live;
23697        expected.provider_session = Some(identity.clone());
23698        expected.active_session = Some(session.clone());
23699        let mut changed = expected.clone();
23700        changed.updated_at_unix_ms += 1;
23701        shared.insert_record(changed).unwrap();
23702
23703        let error = shared
23704            .commit_context_pack_for_session_record(
23705                &expected,
23706                Some(&identity),
23707                &session,
23708                "candidate-exact",
23709                &history,
23710                pack,
23711                false,
23712            )
23713            .unwrap_err();
23714
23715        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23716        assert!(shared
23717            .context_catalog
23718            .read()
23719            .unwrap_or_else(|poisoned| poisoned.into_inner())
23720            .get(&context_id)
23721            .is_none());
23722    }
23723
23724    #[tokio::test]
23725    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23726        let root = temporary_workspace_root("session-task-binding");
23727        std::fs::create_dir_all(&root).unwrap();
23728        let state_path = root.join("node-state.json");
23729        let catalog = active_registry().unwrap();
23730        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23731        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23732        let node_id = NodeId::new("node-1").unwrap();
23733        let mut shared = NodeShared::new(
23734            handle,
23735            "fixture-token".to_owned(),
23736            node_id.clone(),
23737            vec![workspace],
23738            vec![agent("claude")],
23739        );
23740        shared.state_path = Some(state_path.clone());
23741        let record = record("claude", "sr-task");
23742        let record_id = record.record_id.clone();
23743        shared.insert_record(record).unwrap();
23744        shared.persist_state().unwrap();
23745        shared
23746            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23747            .unwrap();
23748
23749        let minted = process_request_inner(
23750            &shared,
23751            77,
23752            ClientRole::Operator,
23753            NodeRequest::SetSessionTask {
23754                record_id: record_id.clone(),
23755                expected_revision: 0,
23756                target: SessionTaskTargetV1::New,
23757            },
23758        )
23759        .await
23760        .unwrap();
23761        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23762            panic!("session task mutation returned another response");
23763        };
23764        let minted_binding = minted.task_binding.clone().unwrap();
23765        let minted_task_id = minted_binding.task_id.clone().unwrap();
23766        assert_eq!(minted_binding.revision, 1);
23767        assert!(minted_task_id.as_str().starts_with("task-"));
23768        assert_eq!(minted_task_id.as_str().len(), 29);
23769
23770        let event_count = shared.current_sequence();
23771        let repeated = process_request_inner(
23772            &shared,
23773            77,
23774            ClientRole::Operator,
23775            NodeRequest::SetSessionTask {
23776                record_id: record_id.clone(),
23777                expected_revision: 1,
23778                target: SessionTaskTargetV1::Existing {
23779                    task_id: minted_task_id.clone(),
23780                },
23781            },
23782        )
23783        .await
23784        .unwrap();
23785        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23786        assert_eq!(shared.current_sequence(), event_count);
23787
23788        let stale = process_request_inner(
23789            &shared,
23790            77,
23791            ClientRole::Operator,
23792            NodeRequest::SetSessionTask {
23793                record_id: record_id.clone(),
23794                expected_revision: 0,
23795                target: SessionTaskTargetV1::Existing {
23796                    task_id: minted_task_id,
23797                },
23798            },
23799        )
23800        .await
23801        .unwrap_err();
23802        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23803
23804        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23805        let assigned = process_request_inner(
23806            &shared,
23807            77,
23808            ClientRole::Operator,
23809            NodeRequest::SetSessionTask {
23810                record_id: record_id.clone(),
23811                expected_revision: 1,
23812                target: SessionTaskTargetV1::Existing {
23813                    task_id: assigned_id.clone(),
23814                },
23815            },
23816        )
23817        .await
23818        .unwrap();
23819        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23820            panic!("session task assignment returned another response");
23821        };
23822        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23823        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23824
23825        let cleared = process_request_inner(
23826            &shared,
23827            77,
23828            ClientRole::Operator,
23829            NodeRequest::SetSessionTask {
23830                record_id: record_id.clone(),
23831                expected_revision: 2,
23832                target: SessionTaskTargetV1::Clear,
23833            },
23834        )
23835        .await
23836        .unwrap();
23837        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23838            panic!("session task clear returned another response");
23839        };
23840        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23841        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23842        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23843        assert_eq!(
23844            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23845            cleared.updated_at_unix_ms,
23846        );
23847
23848        let persisted = std::fs::read(&state_path).unwrap();
23849        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23850        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23851        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23852        std::fs::remove_dir_all(root).unwrap();
23853    }
23854
23855    #[test]
23856    fn session_task_capability_strips_snapshots_events_and_replies() {
23857        let mut bound = record("claude", "sr-task-projection");
23858        bound.updated_at_unix_ms = 2;
23859        bound.task_binding = Some(SessionTaskBindingV1 {
23860            revision: 1,
23861            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23862            changed_at_unix_ms: 2,
23863        });
23864        let request = NodeRequest::SetSessionTask {
23865            record_id: bound.record_id.clone(),
23866            expected_revision: 1,
23867            target: SessionTaskTargetV1::Clear,
23868        };
23869        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23870            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23871        }));
23872        assert!(!request_uses_unnegotiated_capability(
23873            &request,
23874            &baseline_capabilities().unwrap(),
23875        ));
23876        assert!(request_uses_unnegotiated_capability(&request, &[]));
23877
23878        let mut snapshot = terminal_test_shared().snapshot();
23879        snapshot.session_records.push(bound.clone());
23880        let mut reply = ResponseEnvelope {
23881            request_id: 1,
23882            result: Ok(NodeResponse::Resync {
23883                event_sequence: 1,
23884                oldest_available_sequence: 1,
23885                snapshot,
23886                events: vec![NodeEventEnvelope {
23887                    sequence: 1,
23888                    event: NodeEvent::SessionRecordUpserted {
23889                        record: bound.clone(),
23890                    },
23891                }],
23892            }),
23893        };
23894        project_response_without_session_task_binding(&mut reply);
23895        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23896            panic!("task projection changed the response kind");
23897        };
23898        assert!(snapshot.session_records[0].task_binding.is_none());
23899        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23900            panic!("task projection changed the event kind");
23901        };
23902        assert!(record.task_binding.is_none());
23903
23904        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23905            sequence: 2,
23906            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23907        });
23908        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23909            panic!("task event projection changed the event kind");
23910        };
23911        assert!(record.task_binding.is_none());
23912
23913        let mut direct = ResponseEnvelope {
23914            request_id: 2,
23915            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23916        };
23917        project_response_without_session_task_binding(&mut direct);
23918        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23919            panic!("task reply projection changed the response kind");
23920        };
23921        assert!(record.task_binding.is_none());
23922    }
23923
23924    #[test]
23925    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23926        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23927        let workspace = WorkspaceConfig::new(
23928            WorkspaceId::new("primary").unwrap(),
23929            std::env::current_dir().unwrap(),
23930        )
23931        .unwrap();
23932        let config = NodeServerConfig::new(
23933            DEFAULT_NODE_ENDPOINT,
23934            "fixture-token",
23935            NodeId::new("node-1").unwrap(),
23936            [workspace],
23937        )
23938        .unwrap();
23939        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23940    }
23941
23942    #[cfg(all(feature = "fixture", windows))]
23943    #[test]
23944    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23945        let root = temporary_workspace_root("provider-bundle-argv-hold");
23946        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23947        std::fs::create_dir_all(&root).unwrap();
23948        std::fs::create_dir_all(&outside).unwrap();
23949        let proof_path = root.join("provider.proof");
23950        let release_signal = root.join("release.signal");
23951        let config = NodeServerConfig::new(
23952            format!(
23953                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23954                std::process::id(),
23955            ),
23956            "fixture-token",
23957            NodeId::new("provider-bundle-hold-node").unwrap(),
23958            [WorkspaceConfig::new(
23959                WorkspaceId::new("primary").unwrap(),
23960                std::env::current_dir().unwrap(),
23961            )
23962            .unwrap()],
23963        )
23964        .unwrap()
23965        .with_spawn_profiles(SpawnProfileRegistry::new([
23966            crate::protocol::SpawnProfileDefaults {
23967                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23968                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23969                    .unwrap(),
23970                provider: agent("kimi"),
23971                mode: SessionMode::Pty,
23972                terminal_size: gate4agent_types::TerminalSize {
23973                    rows: 24,
23974                    columns: 80,
23975                },
23976                prompt: None,
23977                bundle_id: None,
23978                context_id: None,
23979                environment_profile_id: None,
23980            },
23981        ])
23982        .unwrap());
23983        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23984            config,
23985            agent("kimi"),
23986            proof_path.clone(),
23987            release_signal.clone(),
23988        )
23989        .unwrap();
23990        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23991
23992        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23993            agent("kimi"),
23994            proof_path.clone(),
23995            Some(release_signal.clone()),
23996        )
23997        .unwrap();
23998        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23999        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24000        let release_wait = wrapper
24001            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24002            .unwrap();
24003        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24004        assert!(proof_write < release_wait);
24005        assert!(release_wait < fixture_ready);
24006        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24007        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24008        assert_eq!(
24009            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24010            &[
24011                proof_path.to_string_lossy().into_owned(),
24012                release_signal.to_string_lossy().into_owned(),
24013            ],
24014        );
24015
24016        let error = NodeServer::provider_bundle_argv_fixture_spec(
24017            agent("kimi"),
24018            proof_path,
24019            Some(outside.join("release.signal")),
24020        )
24021        .unwrap_err();
24022        assert!(matches!(
24023            error,
24024            NodeServerError::Registry(message)
24025                if message == "provider bundle argv release path must share the proof parent"
24026        ));
24027        drop(server);
24028        std::fs::remove_dir_all(root).unwrap();
24029        std::fs::remove_dir_all(outside).unwrap();
24030    }
24031
24032    #[cfg(feature = "fixture")]
24033    #[test]
24034    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24035        let root = temporary_workspace_root("context-fixture-registry");
24036        std::fs::create_dir_all(&root).unwrap();
24037        let proof_path = root.join("context.proof");
24038        let config = NodeServerConfig::new(
24039            format!(
24040                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24041                std::process::id(),
24042            ),
24043            "fixture-token",
24044            NodeId::new("context-fixture-node").unwrap(),
24045            [WorkspaceConfig::new(
24046                WorkspaceId::new("primary").unwrap(),
24047                std::env::current_dir().unwrap(),
24048            )
24049            .unwrap()],
24050        )
24051        .unwrap();
24052        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24053        let mut enabled = server
24054            .shared
24055            .enabled_providers
24056            .iter()
24057            .map(AgentId::as_str)
24058            .collect::<Vec<_>>();
24059        enabled.sort_unstable();
24060        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24061        for provider in &server.shared.enabled_providers {
24062            assert!(server
24063                .shared
24064                .provider_adapter_contracts
24065                .iter()
24066                .any(|contract| {
24067                    &contract.provider == provider
24068                        && contract.family == AdapterFamily::History
24069                }));
24070        }
24071        drop(server);
24072        std::fs::remove_dir_all(root).unwrap();
24073    }
24074
24075    #[cfg(feature = "fixture")]
24076    #[test]
24077    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24078        let root = temporary_workspace_root("monitoring-context-fixture");
24079        std::fs::create_dir_all(&root).unwrap();
24080        let proof_path = root.join("context.proof");
24081        let standard = NodeServer::context_pack_fixture_catalog(
24082            proof_path.clone(),
24083            None,
24084            false,
24085        ).unwrap();
24086        let monitoring = NodeServer::context_pack_fixture_catalog(
24087            proof_path.clone(),
24088            None,
24089            true,
24090        ).unwrap();
24091        let claude = AgentId::new("claude").unwrap();
24092        let standard_claude = standard.get(&claude).unwrap();
24093        let monitoring_claude = monitoring.get(&claude).unwrap();
24094        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24095        // standard nor the monitoring fixture declares a hook adapter
24096        // anymore.
24097        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24098        assert_eq!(
24099            monitoring_claude.capabilities.adapters.history,
24100            standard_claude.capabilities.adapters.history,
24101        );
24102        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24103        let codex = AgentId::new("codex").unwrap();
24104        let standard_codex = standard.get(&codex).unwrap();
24105        let monitoring_codex = monitoring.get(&codex).unwrap();
24106        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24107        assert_eq!(
24108            monitoring_codex.capabilities.adapters.history,
24109            standard_codex.capabilities.adapters.history,
24110        );
24111        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24112        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24113            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24114            .unwrap();
24115        for required in [
24116            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24117            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24118            "@('role', 'text')",
24119            "g4a-private-provider-session-canary",
24120            "private-provider-login-identity",
24121            "$rawProof",
24122            "$env:GATE4AGENT_HOOK_URL",
24123            "g4a-private-provider-session-canary",
24124        ] {
24125            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24126        }
24127        for provider in ["grok", "kimi"] {
24128            let provider = AgentId::new(provider).unwrap();
24129            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24130        }
24131
24132        let config = NodeServerConfig::new(
24133            format!(
24134                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24135                std::process::id(),
24136            ),
24137            "fixture-token",
24138            NodeId::new("monitoring-context-fixture-node").unwrap(),
24139            [WorkspaceConfig::new(
24140                WorkspaceId::new("primary").unwrap(),
24141                std::env::current_dir().unwrap(),
24142            )
24143            .unwrap()],
24144        )
24145        .unwrap();
24146        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24147        assert!(server.shared.fixture_semantic_hook_policy);
24148        drop(server);
24149        std::fs::remove_dir_all(root).unwrap();
24150    }
24151
24152    #[cfg(feature = "fixture")]
24153    #[test]
24154    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24155        let root = temporary_workspace_root("kimi-context-only-fixture");
24156        std::fs::create_dir_all(&root).unwrap();
24157        let proof_path = root.join("context.proof");
24158        let standard = NodeServer::context_pack_fixture_catalog(
24159            proof_path.clone(),
24160            None,
24161            false,
24162        ).unwrap();
24163        let kimi = AgentId::new("kimi").unwrap();
24164        let context_only = NodeServer::context_pack_fixture_catalog(
24165            proof_path,
24166            Some(&kimi),
24167            false,
24168        )
24169        .unwrap();
24170
24171        let codex = AgentId::new("codex").unwrap();
24172        assert_eq!(
24173            standard.get(&codex).unwrap().launch.fixed_args,
24174            context_only.get(&codex).unwrap().launch.fixed_args,
24175        );
24176        let script = context_only
24177            .get(&kimi)
24178            .unwrap()
24179            .launch
24180            .fixed_args
24181            .iter()
24182            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24183            .expect("Kimi context-only validation script is missing");
24184        for required in [
24185            "$bundleArgs.Count -ne 0",
24186            "GATE4AGENT_CONTEXT_ROOT",
24187            "context-pack.json",
24188            "$entries.Count -ne 1",
24189            "g4a-context-pack-v1",
24190            "$document.PSObject.Properties['cwd']",
24191            "'claude', 'codex', 'grok', 'kimi'",
24192            "$hasUser",
24193            "$hasAssistant",
24194            "SHA256",
24195            "context-only",
24196        ] {
24197            assert!(script.contains(required), "missing validation: {required}");
24198        }
24199        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24200            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24201        }
24202        assert!(!standard
24203            .get(&kimi)
24204            .unwrap()
24205            .launch
24206            .fixed_args
24207            .iter()
24208            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24209
24210        std::fs::remove_dir_all(root).unwrap();
24211    }
24212
24213    #[cfg(all(feature = "fixture", windows))]
24214    #[tokio::test]
24215    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24216        let root = temporary_workspace_root("kimi-context-only-spawn");
24217        let workspace_root = root.join("workspace");
24218        std::fs::create_dir_all(&workspace_root).unwrap();
24219        let materialization_root = root.join("private-materializations");
24220        let proof_path = root.join("context.proof");
24221        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24222        let workspace_id = WorkspaceId::new("primary").unwrap();
24223        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24224        let endpoint = format!(
24225            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24226            std::process::id(),
24227            unix_time_ms(),
24228        );
24229        let spawn_profiles = SpawnProfileRegistry::new([
24230            crate::protocol::SpawnProfileDefaults {
24231                profile_id: profile_id.clone(),
24232                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24233                provider: agent("kimi"),
24234                mode: SessionMode::Pty,
24235                terminal_size: gate4agent_types::TerminalSize {
24236                    rows: 30,
24237                    columns: 120,
24238                },
24239                prompt: None,
24240                bundle_id: None,
24241                context_id: None,
24242                environment_profile_id: None,
24243            },
24244        ])
24245        .unwrap();
24246        let mut config = NodeServerConfig::new(
24247            &endpoint,
24248            "fixture-token",
24249            node_id.clone(),
24250            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24251        )
24252        .unwrap()
24253        .with_spawn_profiles(spawn_profiles)
24254        .with_session_environment_materialization(
24255            materialization_root,
24256            Arc::new(FixtureSecretResolver {
24257                deny: Arc::new(AtomicBool::new(false)),
24258            }),
24259        )
24260        .unwrap();
24261        config.fixture_raw_pty_runtime = true;
24262        let server = NodeServer::new_context_only_proof_fixture(
24263            config,
24264            agent("kimi"),
24265            proof_path.clone(),
24266        )
24267        .unwrap();
24268        let shared = Arc::clone(&server.shared);
24269        let shutdown = server.shutdown_handle();
24270
24271        let history = HistorySessionRecord {
24272            session_id: "context-only-source-history".to_owned(),
24273            title: None,
24274            cwd: None,
24275            model: None,
24276            message_count: 2,
24277            completed_turn_count: None,
24278            total_tokens: 0,
24279            messages: vec![
24280                gate4agent_types::HistoryMessageRecord {
24281                    role: gate4agent_types::HistoryMessageRole::User,
24282                    text: "continue the bounded parent run through native C2".to_owned(),
24283                },
24284                gate4agent_types::HistoryMessageRecord {
24285                    role: gate4agent_types::HistoryMessageRole::Assistant,
24286                    text: "the exact parent context is ready for the child run".to_owned(),
24287                },
24288            ],
24289        };
24290        let pack = NodeContextPack::export(
24291            ContextPackLineageReceipt {
24292                source_node_id: node_id.clone(),
24293                source_session: SessionAddress {
24294                    workspace_id: workspace_id.clone(),
24295                    session: SessionKey {
24296                        instance_id: AgentInstanceId(71),
24297                        generation: SessionGeneration(1),
24298                    },
24299                },
24300                source_provider: agent("codex"),
24301            },
24302            &history,
24303        )
24304        .unwrap();
24305        let expected_context_hash = digest(&SHA256, pack.bytes())
24306            .as_ref()
24307            .iter()
24308            .map(|byte| format!("{byte:02x}"))
24309            .collect::<String>();
24310        let context = shared
24311            .context_catalog
24312            .write()
24313            .unwrap_or_else(|poisoned| poisoned.into_inner())
24314            .insert(pack)
24315            .unwrap();
24316        let spec = SpawnSpec {
24317            target: crate::protocol::SpawnTarget {
24318                node_id,
24319                workspace_id: workspace_id.clone(),
24320                worktree_id: None,
24321            },
24322            profile_id,
24323            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24324                "target-kimi-r1",
24325            ).unwrap(),
24326            overrides: crate::protocol::SpawnOverrides {
24327                context_id: crate::protocol::SpawnOverride::Set {
24328                    value: context.id.clone(),
24329                },
24330                ..crate::protocol::SpawnOverrides::default()
24331            },
24332            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24333            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24334            required_capabilities: SpawnRequiredCapabilities::default(),
24335        };
24336        let resolved = match shared.resolve_spawn_spec(&spec) {
24337            Ok(resolved) => resolved,
24338            Err(error) => panic!(
24339                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24340                error.code,
24341                error.message,
24342            ),
24343        };
24344        let resolved_context = match shared.resolve_context(&resolved) {
24345            Ok(Some(resolved_context)) => resolved_context,
24346            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24347            Err(error) => panic!(
24348                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24349                error.code,
24350                error.message,
24351            ),
24352        };
24353        assert_eq!(resolved_context, context);
24354        let runtime_policy = match shared
24355            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24356            .await
24357        {
24358            Ok(runtime_policy) => runtime_policy,
24359            Err(error) => panic!(
24360                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24361                error.code,
24362                error.message,
24363            ),
24364        };
24365        assert!(runtime_policy.raw_pty_lifecycle);
24366
24367        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24368        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24369            agent("kimi"),
24370            TransportKind::Pty,
24371            vec![EnvMutation {
24372                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24373                value: Some(OsString::from("must-remain-rejected")),
24374            }],
24375        )
24376        .unwrap();
24377        assert_eq!(
24378            launch_control
24379                .install_native_launch_environment_overlay(
24380                    AgentInstanceId(72),
24381                    generic_overlay,
24382                )
24383                .unwrap_err(),
24384            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24385        );
24386
24387        let diagnostic_address = SessionAddress {
24388            workspace_id: workspace_id.clone(),
24389            session: SessionKey {
24390                instance_id: AgentInstanceId(73),
24391                generation: SessionGeneration(1),
24392            },
24393        };
24394        let (diagnostic_overlay, diagnostic_materialization) = match shared
24395            .prepare_session_materialization(
24396                &diagnostic_address,
24397                &agent("kimi"),
24398                SessionMode::Pty,
24399                None,
24400                None,
24401                Some(&resolved_context),
24402                None,
24403            )
24404        {
24405            Ok(Some(prepared)) => prepared,
24406            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24407            Err(error) => panic!(
24408                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24409                error.code,
24410                error.message,
24411            ),
24412        };
24413        let diagnostic_overlay = diagnostic_overlay
24414            .expect("Kimi context-only materialization omitted its environment overlay");
24415        assert!(matches!(
24416            &diagnostic_overlay,
24417            PreparedNativeLaunchOverlay::Environment(_),
24418        ));
24419        match shared.install_prepared_launch_overlay(
24420            diagnostic_address.session.instance_id,
24421            diagnostic_overlay,
24422        ) {
24423            Ok(None) => {}
24424            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24425            Err(error) => panic!(
24426                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24427                error.code,
24428                error.message,
24429            ),
24430        }
24431        assert!(launch_control.clear_native_instance_launch_overlay(
24432            diagnostic_address.session.instance_id,
24433        ));
24434        drop(diagnostic_materialization);
24435        assert!(shared
24436            .materializations
24437            .lock()
24438            .unwrap_or_else(|poisoned| poisoned.into_inner())
24439            .is_empty());
24440
24441        let node_task = tokio::spawn(server.run());
24442        let ready_client = timeout(Duration::from_secs(5), async {
24443            loop {
24444                match gate4agent_node_wire::LocalNodeClient::connect(
24445                    &endpoint,
24446                    &shared.node_id,
24447                    ClientRole::Observer,
24448                    "fixture-token",
24449                )
24450                .await
24451                {
24452                    Ok(client) => break client,
24453                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24454                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24455                }
24456            }
24457        })
24458        .await
24459        .expect("Kimi context-only Node did not become ready");
24460        assert_eq!(
24461            ready_client
24462                .hello()
24463                .snapshot
24464                .provider_runtime_statuses
24465                .iter()
24466                .find(|status| status.provider().as_str() == "kimi")
24467                .expect("Kimi runtime status is missing")
24468                .mode(),
24469            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24470        );
24471        drop(ready_client);
24472
24473        let receipt = match shared.spawn_from_spec(spec).await {
24474            Ok(receipt) => receipt,
24475            Err(error) => panic!(
24476                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24477                error.code,
24478                error.message,
24479            ),
24480        };
24481        assert_eq!(receipt.provider, agent("kimi"));
24482        assert_eq!(receipt.context.as_ref(), Some(&context));
24483        assert!(receipt.context_binding_is_valid());
24484        timeout(Duration::from_secs(5), async {
24485            while !proof_path.is_file() {
24486                sleep(Duration::from_millis(10)).await;
24487            }
24488        })
24489        .await
24490        .expect("Kimi context-only fixture did not write its proof");
24491
24492        let proof = std::fs::read_to_string(&proof_path).unwrap();
24493        let lines = proof.lines().collect::<Vec<_>>();
24494        assert_eq!(lines.len(), 7);
24495        assert_eq!(lines[0], "context-only");
24496        assert!(Path::new(lines[1]).is_absolute());
24497        assert_ne!(
24498            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24499            std::fs::canonicalize(&workspace_root).unwrap(),
24500        );
24501        assert_eq!(
24502            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24503            std::fs::canonicalize(&workspace_root).unwrap(),
24504        );
24505        assert_eq!(lines[3], expected_context_hash);
24506        assert_eq!(lines[4], "g4a-context-pack-v1");
24507        assert_eq!(lines[5], "codex");
24508        assert_eq!(lines[6], "2");
24509        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24510            session.instance_id == receipt.session.session.instance_id
24511                && session.agent_id.as_str() == "kimi"
24512        }));
24513
24514        shutdown.request_shutdown().await.unwrap();
24515        timeout(Duration::from_secs(5), node_task)
24516            .await
24517            .unwrap()
24518            .unwrap()
24519            .unwrap();
24520        drop(shutdown);
24521        drop(shared);
24522        std::fs::remove_dir_all(root).unwrap();
24523    }
24524
24525    #[cfg(feature = "fixture")]
24526    #[test]
24527    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24528        let root = temporary_workspace_root("clean-exit-constructor");
24529        std::fs::create_dir_all(&root).unwrap();
24530        let config = construction_test_config("clean-exit-constructor")
24531            .with_state_path(root.join("node-state.json"))
24532            .unwrap();
24533        let server = NodeServer::new_clean_exit_fixture(
24534            config,
24535            root.clone(),
24536            root.join("started.marker"),
24537            root.join("release.signal"),
24538        )
24539        .unwrap();
24540        assert_eq!(
24541            server
24542                .shared
24543                .enabled_providers
24544                .iter()
24545                .map(AgentId::as_str)
24546                .collect::<Vec<_>>(),
24547            ["claude"],
24548        );
24549        drop(server);
24550        std::fs::remove_dir_all(root).unwrap();
24551    }
24552
24553    #[test]
24554    fn production_registry_provider_contract_manifest_is_exact() {
24555        let registry = active_registry().unwrap();
24556        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24557        assert_eq!(
24558            providers
24559                .iter()
24560                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24561                .collect::<Vec<_>>(),
24562            vec![
24563                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24564                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24565                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24566                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24567            ]
24568        );
24569        assert_eq!(
24570            adapters
24571                .iter()
24572                .map(|contract| (
24573                    contract.provider.as_str(),
24574                    contract.family,
24575                    contract.adapter_id.as_str(),
24576                    contract.revision.as_str(),
24577                ))
24578                .collect::<Vec<_>>(),
24579            vec![
24580                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24581                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24582                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24583                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24584                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24585                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24586                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24587                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24588                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24589                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24590                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24591                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24592                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24593                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24594                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24595                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24596                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24597                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24598                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24599                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24600                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24601                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24602                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24603            ]
24604        );
24605    }
24606
24607    fn construction_test_config(name: &str) -> NodeServerConfig {
24608        let workspace = WorkspaceConfig::new(
24609            WorkspaceId::new("primary").unwrap(),
24610            std::env::current_dir().unwrap(),
24611        )
24612        .unwrap();
24613        NodeServerConfig::new(
24614            format!(r"\\.\pipe\gate4agent-{name}"),
24615            "fixture-token",
24616            NodeId::new(name).unwrap(),
24617            [workspace],
24618        )
24619        .unwrap()
24620    }
24621
24622    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24623        let adapter = AdapterBinding::new(
24624            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24625            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24626            gate4agent_types::AdapterVerification::SyntheticFixture,
24627        )
24628        .unwrap();
24629        let agent_ids = ["claude", "codex", "kimi"]
24630            .into_iter()
24631            .map(|agent_id| AgentId::new(agent_id).unwrap())
24632            .collect::<Vec<_>>();
24633        let adapters = gate4agent_catalog::AdapterRegistry::new(
24634            [
24635                AdapterFamily::PtySemantic,
24636                AdapterFamily::Pipe,
24637                AdapterFamily::OneShot,
24638                AdapterFamily::Acp,
24639                AdapterFamily::Hook,
24640                AdapterFamily::ManagedHook,
24641                AdapterFamily::History,
24642                AdapterFamily::Resume,
24643                AdapterFamily::SessionOptions,
24644                AdapterFamily::CapabilityProbe,
24645            ]
24646            .into_iter()
24647            .map(|family| gate4agent_catalog::AdapterDescriptor {
24648                family,
24649                binding: adapter.clone(),
24650                agents: agent_ids.clone(),
24651            }),
24652        )
24653        .unwrap();
24654        let specs = agent_ids
24655            .iter()
24656            .map(|agent_id| {
24657                let agent_id = agent_id.as_str();
24658                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24659                spec.revision =
24660                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24661                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24662                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24663                    adapter: adapter.clone(),
24664                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24665                    launch_override: None,
24666                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24667                });
24668                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24669                    adapter: adapter.clone(),
24670                    launch_override: None,
24671                });
24672                spec.capabilities.adapters.hook = Some(adapter.clone());
24673                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24674                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24675                spec.capabilities.adapters.history = Some(adapter.clone());
24676                spec.capabilities.adapters.resume = Some(adapter.clone());
24677                spec.capabilities.adapters.session_options = Some(adapter.clone());
24678                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24679                spec
24680            })
24681            .collect::<Vec<_>>();
24682        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24683    }
24684
24685    /// The real fleet's manifest is accepted by a real server construction.
24686    ///
24687    /// The count is asserted against `active_registry` itself rather than a
24688    /// literal, so this stays a test that construction succeeds for whatever
24689    /// the fleet is -- which is the property worth guarding -- instead of a
24690    /// second place the fleet size is written down and has to be kept in
24691    /// step with the first.
24692    #[test]
24693    fn node_server_construction_accepts_the_production_manifest() {
24694        let registry = active_registry().unwrap();
24695        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24696        assert_eq!(providers.len(), registry.iter().count());
24697        assert!(!providers.is_empty());
24698        let server = NodeServer::new_with_registry(
24699            construction_test_config("manifest-production-bound"),
24700            registry,
24701        )
24702        .unwrap();
24703        drop(server);
24704    }
24705
24706    #[test]
24707    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24708        let registry = maximum_size_provider_manifest_registry();
24709        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24710        assert_eq!(providers.len(), 3);
24711        assert_eq!(adapters.len(), 30);
24712        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24713        let error = match NodeServer::new_with_registry(
24714            construction_test_config("manifest-handshake-overflow"),
24715            registry,
24716        ) {
24717            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24718            Err(error) => error,
24719        };
24720        assert!(matches!(
24721            error,
24722            NodeServerError::ProviderContractManifest(message)
24723                if message.contains("authentication binding length")
24724                    && message.contains("8192-byte limit")
24725        ));
24726    }
24727
24728    #[test]
24729    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24730        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24731        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24732        let registry = AgentRegistry::new([spec]).unwrap();
24733        assert!(matches!(
24734            provider_contract_manifest(&registry),
24735            Err(NodeServerError::ProviderContractManifest(message))
24736                if message.contains("exceeds the 128-byte limit")
24737        ));
24738    }
24739
24740    #[test]
24741    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24742        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24743        spec.revision = "invalid revision".to_owned();
24744        let registry = AgentRegistry::new([spec]).unwrap();
24745        assert!(matches!(
24746            provider_contract_manifest(&registry),
24747            Err(NodeServerError::ProviderContractManifest(message))
24748                if message.contains("bounded lowercase ASCII")
24749        ));
24750    }
24751
24752    #[test]
24753    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24754        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24755        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24756        let conflicting_binding = builtin_registry()
24757            .get_by_id("codex")
24758            .unwrap()
24759            .capabilities
24760            .adapters
24761            .one_shot
24762            .clone()
24763            .unwrap();
24764        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24765        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24766        pipe.adapter = transport_binding;
24767        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24768        let registry = AgentRegistry::new([spec]).unwrap();
24769        assert!(matches!(
24770            provider_contract_manifest(&registry),
24771            Err(NodeServerError::ProviderContractManifest(message))
24772                if message.contains("conflicting OneShot adapter bindings")
24773        ));
24774    }
24775
24776    #[test]
24777    fn public_node_failures_expose_only_fixed_categories() {
24778        let provider = failure(
24779            NodeFailureCode::BackendOperationFailed,
24780            r"provider bearer-secret failed at C:\private\session.jsonl",
24781        );
24782        assert_eq!(provider.message, "backend-operation-failed");
24783        assert!(!provider.message.contains("bearer-secret"));
24784        assert!(!provider.message.contains(r"C:\private"));
24785        let persistence = persistence_failure(io::Error::new(
24786            io::ErrorKind::Other,
24787            r"state write failed at C:\private\state-v1.json",
24788        ));
24789        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24790        assert!(!persistence.message.contains(r"C:\private"));
24791
24792        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24793            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24794            assert_eq!(
24795                generic.to_string(),
24796                "node durable state failed: durable-state-load-failed",
24797            );
24798        }
24799    }
24800
24801    #[test]
24802    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24803        let sequence = b"\x1b[1;5D".to_vec();
24804        assert_eq!(
24805            terminal_bytes_action(sequence.clone()).unwrap(),
24806            InputAction::TerminalBytes(sequence),
24807        );
24808        assert_eq!(
24809            terminal_bytes_action(Vec::new()).unwrap_err().code,
24810            NodeFailureCode::InvalidRequest,
24811        );
24812        assert_eq!(
24813            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24814                .unwrap_err()
24815                .code,
24816            NodeFailureCode::InvalidRequest,
24817        );
24818    }
24819
24820    #[test]
24821    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24822        let root = temporary_workspace_root("exclusive-state-owner");
24823        std::fs::create_dir_all(&root).unwrap();
24824        let workspace = WorkspaceConfig::new(
24825            WorkspaceId::new("primary").unwrap(),
24826            &root,
24827        )
24828        .unwrap();
24829        let state_path = root.join("state-v1.json");
24830        let endpoint = format!(
24831            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24832            std::process::id(),
24833            std::time::SystemTime::now()
24834                .duration_since(std::time::UNIX_EPOCH)
24835                .unwrap()
24836                .as_nanos(),
24837        );
24838        let config = NodeServerConfig::new(
24839            endpoint,
24840            "fixture-token",
24841            NodeId::new("node-1").unwrap(),
24842            [workspace],
24843        )
24844        .unwrap()
24845        .with_state_path(&state_path)
24846        .unwrap();
24847
24848        let first = NodeServer::new(config.clone()).unwrap();
24849        let error = match NodeServer::new(config.clone()) {
24850            Ok(_) => panic!("a second node server acquired the same durable state path"),
24851            Err(error) => error,
24852        };
24853        assert!(matches!(
24854            &error,
24855            NodeServerError::DurableState(error)
24856                if error.kind() == io::ErrorKind::WouldBlock
24857        ));
24858        assert_eq!(
24859            error.to_string(),
24860            "node durable state failed: durable-state-lock-failed",
24861        );
24862        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24863        drop(first);
24864        let second = NodeServer::new(config).unwrap();
24865        drop(second);
24866        std::fs::remove_dir_all(root).unwrap();
24867    }
24868
24869    #[test]
24870    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24871        let catalog = active_registry().unwrap();
24872        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24873        let workspace_id = WorkspaceId::new("primary").unwrap();
24874        let workspace = WorkspaceConfig::new(
24875            workspace_id.clone(),
24876            std::env::current_dir().unwrap(),
24877        )
24878        .unwrap();
24879        let shared = NodeShared::new(
24880            handle,
24881            "fixture-token".to_owned(),
24882            NodeId::new("node-1").unwrap(),
24883            vec![workspace.clone()],
24884            vec![agent("claude"), agent("codex"), agent("kimi")],
24885        );
24886        let instance_id = AgentInstanceId(17);
24887        let generation = SessionGeneration(3);
24888        assert!(shared.address_for(instance_id, generation).is_none());
24889        shared.bind_session(instance_id, workspace_id.clone(), generation);
24890        let address = SessionAddress {
24891            workspace_id,
24892            session: SessionKey { instance_id, generation },
24893        };
24894        assert_eq!(
24895            shared.workspace_root(&address.workspace_id).unwrap(),
24896            workspace.canonical_root,
24897        );
24898        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24899        shared.remove_binding(&address);
24900        assert!(shared.address_for(instance_id, generation).is_none());
24901    }
24902
24903    #[test]
24904    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24905        let address = SessionAddress {
24906            workspace_id: WorkspaceId::new("primary").unwrap(),
24907            session: SessionKey {
24908                instance_id: AgentInstanceId(7),
24909                generation: SessionGeneration(2),
24910            },
24911        };
24912        let record_id = SessionRecordId::new("record-a").unwrap();
24913        let size = gate4agent_types::TerminalSize {
24914            rows: 24,
24915            columns: 80,
24916        };
24917        let address_requests = [
24918            NodeRequest::Resume {
24919                session: address.clone(),
24920                terminal_size: size,
24921                initial_prompt: None,
24922            },
24923            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24924            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24925            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24926            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24927            NodeRequest::TerminalControl {
24928                session: address.clone(),
24929                control: TerminalControl::Enter,
24930            },
24931            NodeRequest::Resize { session: address.clone(), size },
24932            NodeRequest::Interrupt { session: address.clone() },
24933            NodeRequest::Stop { session: address.clone(), force: false },
24934            NodeRequest::Remove { session: address },
24935        ];
24936        for request in &address_requests {
24937            assert!(request_requires_open_provider_ids_with(
24938                request,
24939                |_| Some(agent("third-party-agent")),
24940                |_| Some(agent("claude")),
24941            ));
24942            assert!(!request_requires_open_provider_ids_with(
24943                request,
24944                |_| Some(agent("claude")),
24945                |_| Some(agent("claude")),
24946            ));
24947            assert!(request_requires_open_provider_ids_with(
24948                request,
24949                |_| None,
24950                |_| Some(agent("claude")),
24951            ));
24952        }
24953
24954        let record_requests = [
24955            NodeRequest::RenameSessionRecord {
24956                record_id: record_id.clone(),
24957                display_name: "renamed".to_owned(),
24958            },
24959            NodeRequest::ResumeSessionRecord {
24960                record_id: record_id.clone(),
24961                terminal_size: size,
24962                initial_prompt: None,
24963            },
24964            NodeRequest::ForgetSessionRecord { record_id },
24965        ];
24966        for request in &record_requests {
24967            assert!(request_requires_open_provider_ids_with(
24968                request,
24969                |_| Some(agent("claude")),
24970                |_| Some(agent("grok")),
24971            ));
24972            assert!(!request_requires_open_provider_ids_with(
24973                request,
24974                |_| Some(agent("claude")),
24975                |_| Some(agent("kimi")),
24976            ));
24977            assert!(request_requires_open_provider_ids_with(
24978                request,
24979                |_| Some(agent("claude")),
24980                |_| None,
24981            ));
24982        }
24983
24984        assert!(request_requires_open_provider_ids_with(
24985            &NodeRequest::Spawn {
24986                workspace_id: WorkspaceId::new("primary").unwrap(),
24987                provider: agent("third-party-agent"),
24988                mode: SessionMode::Pty,
24989                terminal_size: size,
24990                initial_prompt: None,
24991            },
24992            |_| None,
24993            |_| None,
24994        ));
24995        assert!(!request_requires_open_provider_ids_with(
24996            &NodeRequest::Snapshot,
24997            |_| None,
24998            |_| None,
24999        ));
25000        assert!(request_requires_open_provider_ids_with(
25001            &NodeRequest::ForgetContextPack {
25002                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25003            },
25004            |_| None,
25005            |_| None,
25006        ));
25007    }
25008
25009    #[test]
25010    fn legacy_projection_filters_open_record_replies_and_event_history() {
25011        let catalog = active_registry().unwrap();
25012        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25013        let workspace = WorkspaceConfig::new(
25014            WorkspaceId::new("primary").unwrap(),
25015            std::env::current_dir().unwrap(),
25016        )
25017        .unwrap();
25018        let shared = NodeShared::new(
25019            handle,
25020            "fixture-token".to_owned(),
25021            NodeId::new("node-1").unwrap(),
25022            vec![workspace],
25023            vec![agent("claude"), agent("grok")],
25024        );
25025        let legacy = record("claude", "legacy-record");
25026        let open = record("grok", "open-record");
25027        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25028            record: legacy.clone(),
25029        });
25030        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25031            record: open.clone(),
25032        });
25033        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25034            record_id: legacy.record_id.clone(),
25035        });
25036        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25037            record_id: open.record_id.clone(),
25038        });
25039        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25040        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25041        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25042        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25043
25044        let mut record_reply = ResponseEnvelope {
25045            request_id: 1,
25046            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25047        };
25048        project_response_legacy_provider_ids(&shared, &mut record_reply);
25049        assert!(matches!(
25050            record_reply.result,
25051            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25052        ));
25053
25054        let mut resync = ResponseEnvelope {
25055            request_id: 2,
25056            result: Ok(NodeResponse::Resync {
25057                event_sequence: open_removed.sequence,
25058                oldest_available_sequence: 1,
25059                snapshot: shared.snapshot(),
25060                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25061            }),
25062        };
25063        project_response_legacy_provider_ids(&shared, &mut resync);
25064        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25065            panic!("legacy projection changed the resync response kind");
25066        };
25067        assert!(snapshot.session_records.iter().all(|item| {
25068            provider_id_is_legacy(&item.provider)
25069        }));
25070        assert_eq!(events.len(), 2);
25071        assert!(events.iter().all(|event| matches!(
25072            &event.event,
25073            NodeEvent::SessionRecordUpserted { record }
25074                if provider_id_is_legacy(&record.provider)
25075        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25076    }
25077
25078    #[test]
25079    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25080        let catalog = active_registry().unwrap();
25081        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25082        let workspace_id = WorkspaceId::new("primary").unwrap();
25083        let workspace = WorkspaceConfig::new(
25084            workspace_id.clone(),
25085            std::env::current_dir().unwrap(),
25086        )
25087        .unwrap();
25088        let shared = NodeShared::new(
25089            handle,
25090            "fixture-token".to_owned(),
25091            NodeId::new("node-1").unwrap(),
25092            vec![workspace],
25093            vec![agent("claude")],
25094        );
25095        for index in 0..CONTROL_SESSIONS_MAX {
25096            shared.bind_session(
25097                AgentInstanceId((index + 1) as u64),
25098                workspace_id.clone(),
25099                SessionGeneration(1),
25100            );
25101        }
25102        let error = shared.ensure_binding_capacity().unwrap_err();
25103        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25104    }
25105
25106    #[tokio::test]
25107    async fn provider_spawn_admission_rejects_before_session_mutation() {
25108        let catalog = active_registry().unwrap();
25109        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25110        let workspace = WorkspaceConfig::new(
25111            WorkspaceId::new("primary").unwrap(),
25112            std::env::current_dir().unwrap(),
25113        )
25114        .unwrap();
25115        let mut shared = NodeShared::new(
25116            handle,
25117            "fixture-token".to_owned(),
25118            NodeId::new("node-provider-admission").unwrap(),
25119            vec![workspace],
25120            vec![agent("claude")],
25121        );
25122        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25123            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25124        ])
25125        .unwrap();
25126        let terminal_size = gate4agent_types::TerminalSize {
25127            rows: 24,
25128            columns: 80,
25129        };
25130
25131        let unavailable = shared
25132            .spawn_session(
25133                WorkspaceId::new("primary").unwrap(),
25134                agent("claude"),
25135                SessionMode::Pty,
25136                terminal_size,
25137                None,
25138            )
25139            .await
25140            .unwrap_err();
25141        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25142        assert_eq!(unavailable.message, "backend-operation-failed");
25143
25144        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25145            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25146                agent("claude"),
25147                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25148            ),
25149        ])
25150        .unwrap();
25151        let semantic = shared
25152            .spawn_session(
25153                WorkspaceId::new("primary").unwrap(),
25154                agent("claude"),
25155                SessionMode::Inline,
25156                terminal_size,
25157                None,
25158            )
25159            .await
25160            .unwrap_err();
25161        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25162        assert_eq!(semantic.message, "unsupported-capability");
25163
25164        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25165        assert!(shared
25166            .session_bindings
25167            .lock()
25168            .unwrap_or_else(|poisoned| poisoned.into_inner())
25169            .is_empty());
25170        assert!(shared
25171            .session_records
25172            .lock()
25173            .unwrap_or_else(|poisoned| poisoned.into_inner())
25174            .records
25175            .is_empty());
25176        assert!(shared.handle.snapshot().sessions.is_empty());
25177    }
25178
25179    #[tokio::test]
25180    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25181        let catalog = active_registry().unwrap();
25182        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25183        let workspace_id = WorkspaceId::new("primary").unwrap();
25184        let workspace = WorkspaceConfig::new(
25185            workspace_id.clone(),
25186            std::env::current_dir().unwrap(),
25187        )
25188        .unwrap();
25189        let shared = NodeShared::new(
25190            handle,
25191            "fixture-token".to_owned(),
25192            NodeId::new("node-raw-spawn-record").unwrap(),
25193            vec![workspace],
25194            vec![agent("claude")],
25195        );
25196        let address = SessionAddress {
25197            workspace_id,
25198            session: SessionKey {
25199                instance_id: AgentInstanceId(71),
25200                generation: SessionGeneration::default(),
25201            },
25202        };
25203
25204        let record_id = shared
25205            .bind_spawn_session(
25206                &address,
25207                agent("claude"),
25208                SessionMode::Pty,
25209                ProviderRuntimePolicy::raw_pty(),
25210                SpawnRecordPolicy::Always,
25211                None,
25212            )
25213            .unwrap()
25214            .unwrap();
25215        shared
25216            .handle
25217            .dispatch(shared.prepare_command(ControlCommand::Register {
25218                instance_id: address.session.instance_id,
25219                agent_id: agent("claude"),
25220                transport: TransportKind::Pty,
25221            }))
25222            .unwrap();
25223        runtime.tick().await;
25224
25225        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25226            session.instance_id == address.session.instance_id
25227                && session.generation == address.session.generation
25228        }));
25229        let record = shared.record(&record_id).unwrap();
25230        assert_eq!(record.display_name, "claude #1");
25231        assert_eq!(record.state, ManagedSessionState::Live);
25232        assert_eq!(record.active_session.as_ref(), Some(&address));
25233        assert!(record.provider_session.is_none());
25234        let binding_record_id = shared
25235            .session_bindings
25236            .lock()
25237            .unwrap_or_else(|poisoned| poisoned.into_inner())
25238            .get(&address.session.instance_id)
25239            .and_then(|binding| binding.record_id.clone());
25240        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25241
25242        let renamed = shared
25243            .rename_session_record(&record_id, "raw review".to_owned())
25244            .unwrap();
25245        assert_eq!(renamed.record_id, record_id);
25246        assert_eq!(renamed.display_name, "raw review");
25247        shared.reconcile_managed_record(
25248            &record_id,
25249            &address,
25250            &ControlEvent {
25251                sequence: 1,
25252                command_id: None,
25253                instance_id: address.session.instance_id,
25254                generation: address.session.generation,
25255                event: ControlEventKind::Running { process_id: None },
25256            },
25257            false,
25258            false,
25259        );
25260        let running = shared.record(&record_id).unwrap();
25261        assert_eq!(running.state, ManagedSessionState::Live);
25262        assert_eq!(running.active_session.as_ref(), Some(&address));
25263        assert_eq!(running.display_name, "raw review");
25264
25265        shared.reconcile_managed_record(
25266            &record_id,
25267            &address,
25268            &ControlEvent {
25269                sequence: 2,
25270                command_id: None,
25271                instance_id: address.session.instance_id,
25272                generation: address.session.generation,
25273                event: ControlEventKind::Exited {
25274                    exit_code: Some(0),
25275                    forced: false,
25276                },
25277            },
25278            false,
25279            false,
25280        );
25281        let detached = shared.record(&record_id).unwrap();
25282        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25283        assert!(detached.active_session.is_none());
25284        assert_eq!(detached.display_name, "raw review");
25285        let renamed_detached = shared
25286            .rename_session_record(&record_id, "raw review detached".to_owned())
25287            .unwrap();
25288        assert_eq!(renamed_detached.record_id, record_id);
25289        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25290        assert!(renamed_detached.active_session.is_none());
25291        assert_eq!(renamed_detached.display_name, "raw review detached");
25292        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25293    }
25294
25295    #[test]
25296    fn legacy_raw_spawn_binding_remains_untracked() {
25297        let catalog = active_registry().unwrap();
25298        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25299        let workspace_id = WorkspaceId::new("primary").unwrap();
25300        let workspace = WorkspaceConfig::new(
25301            workspace_id.clone(),
25302            std::env::current_dir().unwrap(),
25303        )
25304        .unwrap();
25305        let shared = NodeShared::new(
25306            handle,
25307            "fixture-token".to_owned(),
25308            NodeId::new("node-legacy-raw-spawn").unwrap(),
25309            vec![workspace],
25310            vec![agent("claude")],
25311        );
25312        let address = SessionAddress {
25313            workspace_id,
25314            session: SessionKey {
25315                instance_id: AgentInstanceId(72),
25316                generation: SessionGeneration::default(),
25317            },
25318        };
25319
25320        let record_id = shared
25321            .bind_spawn_session(
25322                &address,
25323                agent("claude"),
25324                SessionMode::Pty,
25325                ProviderRuntimePolicy::raw_pty(),
25326                SpawnRecordPolicy::ProviderIdentityOnly,
25327                None,
25328            )
25329            .unwrap();
25330
25331        assert!(record_id.is_none());
25332        let binding_record_id = shared
25333            .session_bindings
25334            .lock()
25335            .unwrap_or_else(|poisoned| poisoned.into_inner())
25336            .get(&address.session.instance_id)
25337            .and_then(|binding| binding.record_id.clone());
25338        assert!(binding_record_id.is_none());
25339        assert!(shared.snapshot().session_records.is_empty());
25340    }
25341
25342    #[tokio::test]
25343    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25344        let catalog = active_registry().unwrap();
25345        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25346        let workspace_id = WorkspaceId::new("primary").unwrap();
25347        let workspace = WorkspaceConfig::new(
25348            workspace_id.clone(),
25349            std::env::current_dir().unwrap(),
25350        )
25351        .unwrap();
25352        let mut shared = NodeShared::new(
25353            handle,
25354            "fixture-token".to_owned(),
25355            NodeId::new("node-raw-semantic-gate").unwrap(),
25356            vec![workspace],
25357            vec![agent("claude")],
25358        );
25359        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25360            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25361        ])
25362        .unwrap();
25363        shared
25364            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25365            .unwrap();
25366        let address = SessionAddress {
25367            workspace_id,
25368            session: SessionKey {
25369                instance_id: AgentInstanceId(72),
25370                generation: SessionGeneration::default(),
25371            },
25372        };
25373        shared.bind_session_with_policy(
25374            &address,
25375            ProviderRuntimePolicy::raw_pty(),
25376            None,
25377        );
25378        shared
25379            .handle
25380            .dispatch(shared.prepare_command(ControlCommand::Register {
25381                instance_id: address.session.instance_id,
25382                agent_id: agent("claude"),
25383                transport: TransportKind::Pty,
25384            }))
25385            .unwrap();
25386        runtime.tick().await;
25387        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25388        let revision_before = shared.handle.snapshot().revision;
25389        let history_sequence_before = shared.current_sequence();
25390        let terminal_size = gate4agent_types::TerminalSize {
25391            rows: 24,
25392            columns: 80,
25393        };
25394
25395        for request in [
25396            NodeRequest::Prompt {
25397                session: address.clone(),
25398                text: "prompt".to_owned(),
25399            },
25400            NodeRequest::Paste {
25401                session: address.clone(),
25402                text: "paste".to_owned(),
25403            },
25404            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25405            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25406            // (promptless) resume is a raw PTY relaunch and IS admitted under
25407            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25408            // in `provider_runtime.rs`; it is deliberately not exercised here.
25409            NodeRequest::Resume {
25410                session: address.clone(),
25411                terminal_size,
25412                initial_prompt: Some("resume".to_owned()),
25413            },
25414        ] {
25415            let error = process_request_inner(
25416                &shared,
25417                77,
25418                ClientRole::Operator,
25419                request,
25420            )
25421            .await
25422            .unwrap_err();
25423            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25424        }
25425
25426        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25427        assert_eq!(shared.handle.snapshot().revision, revision_before);
25428        assert_eq!(shared.current_sequence(), history_sequence_before);
25429        let bindings = shared
25430            .session_bindings
25431            .lock()
25432            .unwrap_or_else(|poisoned| poisoned.into_inner());
25433        let binding = bindings.get(&address.session.instance_id).unwrap();
25434        assert_eq!(binding.generation, address.session.generation);
25435        assert!(binding.pending_resume.is_none());
25436        assert!(binding.record_id.is_none());
25437    }
25438
25439    #[tokio::test]
25440    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25441        let catalog = active_registry().unwrap();
25442        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25443        let primary = WorkspaceConfig::new(
25444            WorkspaceId::new("primary").unwrap(),
25445            std::env::current_dir().unwrap(),
25446        )
25447        .unwrap();
25448        let shared = NodeShared::new(
25449            handle,
25450            "fixture-token".to_owned(),
25451            NodeId::new("node-1").unwrap(),
25452            vec![primary],
25453            vec![agent("claude")],
25454        );
25455        shared
25456            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25457            .unwrap();
25458        let before = shared.snapshot();
25459        let error = process_request_inner(
25460            &shared,
25461            77,
25462            ClientRole::Operator,
25463            NodeRequest::RegisterWorkspace {
25464                workspace_id: WorkspaceId::new("foreign").unwrap(),
25465                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25466            },
25467        )
25468        .await
25469        .unwrap_err();
25470        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25471        assert_eq!(error.message, "invalid-request");
25472        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25473    }
25474
25475    #[tokio::test]
25476    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25477        let catalog = active_registry().unwrap();
25478        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25479        let primary_id = WorkspaceId::new("primary").unwrap();
25480        let primary = WorkspaceConfig::new(
25481            primary_id.clone(),
25482            std::env::current_dir().unwrap(),
25483        )
25484        .unwrap();
25485        let shared = NodeShared::new(
25486            handle,
25487            "fixture-token".to_owned(),
25488            NodeId::new("node-1").unwrap(),
25489            vec![primary.clone()],
25490            vec![agent("claude")],
25491        );
25492        let secondary_id = WorkspaceId::new("secondary").unwrap();
25493        let secondary = shared
25494            .register_workspace(
25495                secondary_id.clone(),
25496                std::env::temp_dir().to_string_lossy().into_owned(),
25497            )
25498            .await
25499            .unwrap();
25500        assert_eq!(secondary.workspace_id, secondary_id);
25501        assert!(secondary.sessions.is_empty());
25502        assert_eq!(shared.snapshot().workspaces.len(), 2);
25503
25504        let duplicate_id = shared
25505            .register_workspace(
25506                secondary_id.clone(),
25507                primary.canonical_root().to_owned(),
25508            )
25509            .await
25510            .unwrap_err();
25511        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25512        let duplicate_root = shared
25513            .register_workspace(
25514                WorkspaceId::new("duplicate-root").unwrap(),
25515                windows_path_text(&secondary.canonical_root).to_owned(),
25516            )
25517            .await
25518            .unwrap_err();
25519        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25520
25521        let address = SessionAddress {
25522            workspace_id: secondary_id.clone(),
25523            session: SessionKey {
25524                instance_id: AgentInstanceId(41),
25525                generation: SessionGeneration(1),
25526            },
25527        };
25528        shared.bind_session(
25529            address.session.instance_id,
25530            secondary_id.clone(),
25531            address.session.generation,
25532        );
25533        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25534        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25535        shared.remove_binding(&address);
25536        shared.unregister_workspace(&secondary_id).unwrap();
25537        assert_eq!(shared.snapshot().workspaces.len(), 1);
25538
25539        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25540        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25541        let history = shared
25542            .history
25543            .lock()
25544            .unwrap_or_else(|poisoned| poisoned.into_inner());
25545        assert!(matches!(
25546            &history.events[0],
25547            NodeEventEnvelope {
25548                sequence: 1,
25549                event: NodeEvent::WorkspaceAdded { workspace },
25550            } if workspace.workspace_id == secondary_id
25551        ));
25552        assert!(matches!(
25553            &history.events[1],
25554            NodeEventEnvelope {
25555                sequence: 2,
25556                event: NodeEvent::WorkspaceRemoved { workspace_id },
25557            } if workspace_id == &secondary_id
25558        ));
25559    }
25560
25561    #[tokio::test]
25562    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25563        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25564        std::fs::create_dir_all(&secondary_root).unwrap();
25565        let catalog = active_registry().unwrap();
25566        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25567        let primary_id = WorkspaceId::new("primary").unwrap();
25568        let secondary_id = WorkspaceId::new("secondary").unwrap();
25569        let shared = NodeShared::new(
25570            handle,
25571            "fixture-token".to_owned(),
25572            NodeId::new("node-session-reference-index").unwrap(),
25573            vec![
25574                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25575                    .unwrap(),
25576                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25577            ],
25578            vec![agent("claude"), agent("codex")],
25579        );
25580        let identity = ProviderSessionIdentity {
25581            key: gate4agent_types::ProviderSessionKey::SessionId,
25582            id: "provider-session-42".to_owned(),
25583            transcript_path: None,
25584        };
25585        let index_request = |workspace_id: WorkspaceId,
25586                             provider: AgentId,
25587                             identity: ProviderSessionIdentity,
25588                             display_name: &str| {
25589            NodeRequest::IndexProviderSession {
25590                workspace_id,
25591                provider,
25592                identity,
25593                display_name: display_name.to_owned(),
25594            }
25595        };
25596        let request = index_request(
25597            primary_id.clone(),
25598            agent("claude"),
25599            identity.clone(),
25600            "release shepherd",
25601        );
25602        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25603            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25604        }));
25605        let observer = process_request_inner(
25606            &shared,
25607            77,
25608            ClientRole::Observer,
25609            request.clone(),
25610        )
25611        .await
25612        .unwrap_err();
25613        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25614        let uncontrolled = process_request_inner(
25615            &shared,
25616            77,
25617            ClientRole::Operator,
25618            request.clone(),
25619        )
25620        .await
25621        .unwrap_err();
25622        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25623        shared
25624            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25625            .unwrap();
25626
25627        let indexed = process_request_inner(
25628            &shared,
25629            77,
25630            ClientRole::Operator,
25631            request.clone(),
25632        )
25633        .await
25634        .unwrap();
25635        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25636            panic!("provider session reference index returned another response");
25637        };
25638        assert_eq!(record.state, ManagedSessionState::Dormant);
25639        assert_eq!(record.mode, SessionMode::Pty);
25640        assert_eq!(record.workspace_id, primary_id);
25641        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25642        assert!(record.active_session.is_none());
25643        assert!(record.environment_profile.is_none());
25644        assert!(record.bundle.is_none());
25645        assert!(record.context.is_none());
25646
25647        let repeated = process_request_inner(
25648            &shared,
25649            77,
25650            ClientRole::Operator,
25651            index_request(
25652                primary_id.clone(),
25653                agent("claude"),
25654                identity.clone(),
25655                "ignored replacement",
25656            ),
25657        )
25658        .await
25659        .unwrap();
25660        assert!(matches!(
25661            repeated,
25662            NodeResponse::ProviderSessionIndexed { record: ref existing }
25663                if existing.record_id == record.record_id
25664                    && existing.display_name == "release shepherd"
25665        ));
25666        let conflicting_workspace = process_request_inner(
25667            &shared,
25668            77,
25669            ClientRole::Operator,
25670            index_request(
25671                secondary_id,
25672                agent("claude"),
25673                identity.clone(),
25674                "release shepherd",
25675            ),
25676        )
25677        .await
25678        .unwrap_err();
25679        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25680        let conflicting_provider = process_request_inner(
25681            &shared,
25682            77,
25683            ClientRole::Operator,
25684            index_request(
25685                primary_id.clone(),
25686                agent("codex"),
25687                identity.clone(),
25688                "release shepherd",
25689            ),
25690        )
25691        .await
25692        .unwrap_err();
25693        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25694        let transcript_rejected = process_request_inner(
25695            &shared,
25696            77,
25697            ClientRole::Operator,
25698            index_request(
25699                primary_id,
25700                agent("claude"),
25701                ProviderSessionIdentity {
25702                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25703                    ..identity
25704                },
25705                "release shepherd",
25706            ),
25707        )
25708        .await
25709        .unwrap_err();
25710        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25711        let upserts = shared
25712            .history
25713            .lock()
25714            .unwrap_or_else(|poisoned| poisoned.into_inner())
25715            .events
25716            .iter()
25717            .filter(|event| matches!(
25718                &event.event,
25719                NodeEvent::SessionRecordUpserted { record: published }
25720                    if published.record_id == record.record_id
25721            ))
25722            .count();
25723        assert_eq!(upserts, 1);
25724        std::fs::remove_dir_all(secondary_root).unwrap();
25725    }
25726
25727    #[tokio::test]
25728    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25729        let fixture_root = temporary_workspace_root("standalone-independent");
25730        let target = fixture_root.join("independent");
25731        std::fs::create_dir_all(&fixture_root).unwrap();
25732        let catalog = active_registry().unwrap();
25733        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25734        let selected = WorkspaceConfig::new(
25735            WorkspaceId::new("primary").unwrap(),
25736            std::env::current_dir().unwrap(),
25737        )
25738        .unwrap();
25739        let selected_root = selected.canonical_root().to_owned();
25740        let shared = NodeShared::new(
25741            handle,
25742            "fixture-token".to_owned(),
25743            NodeId::new("node-standalone").unwrap(),
25744            vec![selected],
25745            vec![agent("claude")],
25746        );
25747        let workspace_id = WorkspaceId::new("independent").unwrap();
25748        let request = NodeRequest::CreateStandaloneWorkspace {
25749            workspace_id: workspace_id.clone(),
25750            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25751            initial_branch: Some("main".to_owned()),
25752        };
25753        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25754            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25755        }));
25756        assert!(!request_uses_unnegotiated_capability(
25757            &request,
25758            &baseline_capabilities().unwrap(),
25759        ));
25760        assert!(request_uses_unnegotiated_capability(&request, &[]));
25761
25762        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25763            .await
25764            .unwrap_err();
25765        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25766        assert!(!target.exists());
25767        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25768            .await
25769            .unwrap_err();
25770        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25771        assert!(!target.exists());
25772        shared
25773            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25774            .unwrap();
25775
25776        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25777            .await
25778            .unwrap();
25779        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25780            panic!("standalone workspace creation returned another response");
25781        };
25782        assert_eq!(workspace.workspace_id, workspace_id);
25783        assert!(target.join(".git").is_dir());
25784        let branch = run_git_bounded(
25785            target.to_str().unwrap(),
25786            &["branch", "--show-current"],
25787            GIT_OUTPUT_MAX_BYTES,
25788        )
25789        .await
25790        .unwrap();
25791        assert!(branch.success);
25792        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25793        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25794        assert_eq!(worktrees.len(), 1);
25795        let listed_root = WorkspaceConfig::new(
25796            WorkspaceId::new("listed-independent").unwrap(),
25797            &worktrees[0].path,
25798        )
25799        .unwrap();
25800        assert!(platform::roots_equal(
25801            listed_root.canonical_root(),
25802            windows_path_text(&workspace.canonical_root),
25803        ));
25804        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25805        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25806            snapshot.workspace_id == workspace_id
25807                && snapshot.canonical_root == workspace.canonical_root
25808        }));
25809        assert!(shared
25810            .history
25811            .lock()
25812            .unwrap_or_else(|poisoned| poisoned.into_inner())
25813            .events
25814            .iter()
25815            .any(|event| matches!(
25816                &event.event,
25817                NodeEvent::WorkspaceAdded { workspace }
25818                    if workspace.workspace_id == workspace_id
25819            )));
25820        std::fs::remove_dir_all(fixture_root).unwrap();
25821    }
25822
25823    #[tokio::test]
25824    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25825        let fixture_root = temporary_workspace_root("standalone-compensation");
25826        let created_target = fixture_root.join("created-target");
25827        let existing_target = fixture_root.join("existing-target");
25828        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25829        let invalid_state_path = invalid_state_parent.join("state.json");
25830        std::fs::create_dir_all(&existing_target).unwrap();
25831        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25832        let catalog = active_registry().unwrap();
25833        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25834        let primary = WorkspaceConfig::new(
25835            WorkspaceId::new("primary").unwrap(),
25836            std::env::current_dir().unwrap(),
25837        )
25838        .unwrap();
25839        let mut shared = NodeShared::new(
25840            handle,
25841            "fixture-token".to_owned(),
25842            NodeId::new("node-standalone-compensation").unwrap(),
25843            vec![primary],
25844            vec![agent("claude")],
25845        );
25846        shared.state_path = Some(invalid_state_path);
25847
25848        let created_recovery = shared
25849            .create_standalone_workspace(
25850                WorkspaceId::new("compensated").unwrap(),
25851                created_target.to_string_lossy().into_owned(),
25852                Some("main".to_owned()),
25853            )
25854            .await
25855            .unwrap_err();
25856        assert_eq!(
25857            created_recovery.code,
25858            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25859        );
25860        assert!(created_target.join(".git").is_dir());
25861        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25862            workspace.workspace_id.as_str() != "compensated"
25863        }));
25864
25865        let existing_recovery = shared
25866            .create_standalone_workspace(
25867                WorkspaceId::new("restored").unwrap(),
25868                existing_target.to_string_lossy().into_owned(),
25869                None,
25870            )
25871            .await
25872            .unwrap_err();
25873        assert_eq!(
25874            existing_recovery.code,
25875            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25876        );
25877        assert!(existing_target.is_dir());
25878        assert!(existing_target.join(".git").is_dir());
25879        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25880            workspace.workspace_id.as_str() != "restored"
25881        }));
25882
25883        let registered_empty = fixture_root.join("registered-empty");
25884        std::fs::create_dir(&registered_empty).unwrap();
25885        let catalog = active_registry().unwrap();
25886        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25887        let registered = WorkspaceConfig::new(
25888            WorkspaceId::new("registered-empty").unwrap(),
25889            &registered_empty,
25890        )
25891        .unwrap();
25892        let duplicate_shared = NodeShared::new(
25893            handle,
25894            "fixture-token".to_owned(),
25895            NodeId::new("node-standalone-duplicate-root").unwrap(),
25896            vec![registered],
25897            vec![agent("claude")],
25898        );
25899        let duplicate = duplicate_shared
25900            .create_standalone_workspace(
25901                WorkspaceId::new("duplicate-root").unwrap(),
25902                registered_empty.to_string_lossy().into_owned(),
25903                None,
25904            )
25905            .await
25906            .unwrap_err();
25907        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25908        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25909        std::fs::remove_dir_all(fixture_root).unwrap();
25910    }
25911
25912    #[test]
25913    fn managed_record_id_collision_preserves_the_existing_record() {
25914        let catalog = active_registry().unwrap();
25915        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25916        let workspace = WorkspaceConfig::new(
25917            WorkspaceId::new("primary").unwrap(),
25918            std::env::current_dir().unwrap(),
25919        )
25920        .unwrap();
25921        let shared = NodeShared::new(
25922            handle,
25923            "fixture-token".to_owned(),
25924            NodeId::new("node-1").unwrap(),
25925            vec![workspace.clone()],
25926            vec![agent("claude")],
25927        );
25928        let record_id = SessionRecordId::new("sr-collision").unwrap();
25929        let original = ManagedSessionRecord {
25930            record_id: record_id.clone(),
25931            display_name: "original".to_owned(),
25932            provider: agent("claude"),
25933            mode: SessionMode::Pty,
25934            state: ManagedSessionState::Dormant,
25935            workspace_id: workspace.workspace_id,
25936            canonical_root: opaque_windows_path(workspace.canonical_root),
25937            provider_session: None,
25938            active_session: None,
25939            environment_profile: None,
25940            bundle: None,
25941            context_id: None,
25942            context: None,
25943            exported_context: None,
25944            task_binding: None,
25945            created_at_unix_ms: 1,
25946            updated_at_unix_ms: 1,
25947            last_error: None,
25948        };
25949        shared.insert_record(original.clone()).unwrap();
25950        let mut collision = original.clone();
25951        collision.display_name = "replacement".to_owned();
25952        let error = shared.insert_record(collision).unwrap_err();
25953        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25954        assert_eq!(shared.record(&record_id).unwrap(), original);
25955    }
25956
25957    #[test]
25958    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25959        let catalog = active_registry().unwrap();
25960        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25961        let workspace = WorkspaceConfig::new(
25962            WorkspaceId::new("primary").unwrap(),
25963            std::env::current_dir().unwrap(),
25964        )
25965        .unwrap();
25966        let shared = NodeShared::new(
25967            handle,
25968            "fixture-token".to_owned(),
25969            NodeId::new("node-1").unwrap(),
25970            vec![workspace.clone()],
25971            vec![agent("claude")],
25972        );
25973        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25974        shared
25975            .insert_record(ManagedSessionRecord {
25976                record_id: record_id.clone(),
25977                display_name: "runtime error".to_owned(),
25978                provider: agent("claude"),
25979                mode: SessionMode::Pty,
25980                state: ManagedSessionState::IdentityPending,
25981                workspace_id: workspace.workspace_id,
25982                canonical_root: opaque_windows_path(workspace.canonical_root),
25983                provider_session: None,
25984                active_session: None,
25985                environment_profile: None,
25986                bundle: None,
25987                context_id: None,
25988                context: None,
25989                exported_context: None,
25990                task_binding: None,
25991                created_at_unix_ms: 1,
25992                updated_at_unix_ms: 1,
25993                last_error: None,
25994            })
25995            .unwrap();
25996
25997        shared
25998            .mark_record_error(
25999                &record_id,
26000                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26001            )
26002            .unwrap();
26003        let record = shared.record(&record_id).unwrap();
26004        assert_eq!(record.state, ManagedSessionState::Unavailable);
26005        assert_eq!(
26006            record.last_error.as_deref(),
26007            Some("provider-runtime-failed"),
26008        );
26009        let snapshot = shared.snapshot();
26010        assert_eq!(snapshot.session_records, vec![record]);
26011    }
26012
26013    /// Regression coverage for the "operator sees `deadline` instead of the
26014    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26015    /// able to learn a command's asynchronous kernel rejection (here,
26016    /// specifically classified as `UnsupportedTransport`) exactly once, and
26017    /// must not still find it after that -- proving the fast path this
26018    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26019    /// loop) drains the reason instead of leaving it to be rediscovered (or
26020    /// missed) on a later poll.
26021    #[test]
26022    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26023        let catalog = active_registry().unwrap();
26024        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26025        let workspace = WorkspaceConfig::new(
26026            WorkspaceId::new("primary").unwrap(),
26027            std::env::current_dir().unwrap(),
26028        )
26029        .unwrap();
26030        let shared = NodeShared::new(
26031            handle,
26032            "fixture-token".to_owned(),
26033            NodeId::new("node-1").unwrap(),
26034            vec![workspace],
26035            vec![agent("claude")],
26036        );
26037
26038        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26039
26040        shared.record_rejected_command(
26041            CommandId(1),
26042            "agent 'claude' does not support transport Acp".to_owned(),
26043            true,
26044        );
26045        // An unrelated command_id must not see another command's reason.
26046        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26047
26048        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26049        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26050        assert!(reason.unsupported_transport);
26051
26052        // Consumed: a second read for the same command_id finds nothing.
26053        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26054
26055        // A rejection unrelated to transport support keeps its own
26056        // classification distinct, so the reader can choose the generic
26057        // `BackendOperationFailed` code instead of misreporting
26058        // `UnsupportedTransport`.
26059        shared.record_rejected_command(
26060            CommandId(3),
26061            "backend busy".to_owned(),
26062            false,
26063        );
26064        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26065        assert!(!unclassified.unsupported_transport);
26066    }
26067
26068    #[test]
26069    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26070        let catalog = active_registry().unwrap();
26071        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26072        let workspace = WorkspaceConfig::new(
26073            WorkspaceId::new("primary").unwrap(),
26074            std::env::current_dir().unwrap(),
26075        )
26076        .unwrap();
26077        let shared = NodeShared::new(
26078            handle,
26079            "fixture-token".to_owned(),
26080            NodeId::new("node-identity-policy").unwrap(),
26081            vec![workspace.clone()],
26082            vec![agent("claude")],
26083        );
26084        let address = SessionAddress {
26085            workspace_id: workspace.workspace_id.clone(),
26086            session: SessionKey {
26087                instance_id: AgentInstanceId(90),
26088                generation: SessionGeneration(1),
26089            },
26090        };
26091        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26092        shared
26093            .insert_record(ManagedSessionRecord {
26094                record_id: record_id.clone(),
26095                display_name: "policy denied".to_owned(),
26096                provider: agent("claude"),
26097                mode: SessionMode::Pty,
26098                state: ManagedSessionState::IdentityPending,
26099                workspace_id: workspace.workspace_id,
26100                canonical_root: opaque_windows_path(workspace.canonical_root),
26101                provider_session: None,
26102                active_session: Some(address.clone()),
26103                environment_profile: None,
26104                bundle: None,
26105                context_id: None,
26106                context: None,
26107                exported_context: None,
26108                task_binding: None,
26109                created_at_unix_ms: 1,
26110                updated_at_unix_ms: 1,
26111                last_error: None,
26112            })
26113            .unwrap();
26114        let event = ControlEvent {
26115            sequence: 1,
26116            command_id: None,
26117            instance_id: address.session.instance_id,
26118            generation: address.session.generation,
26119            event: ControlEventKind::ProviderEvent {
26120                sequence: 1,
26121                source: gate4agent_types::ProviderSource {
26122                    family: AdapterFamily::PtySemantic,
26123                    binding: AdapterBinding::new(
26124                        gate4agent_types::AdapterId::new("claude").unwrap(),
26125                        "fixture/v1",
26126                        gate4agent_types::AdapterVerification::SyntheticFixture,
26127                    )
26128                    .unwrap(),
26129                },
26130                source_sequence: 1,
26131                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26132                    identity: gate4agent_types::ProviderSessionIdentity {
26133                        key: gate4agent_types::ProviderSessionKey::SessionId,
26134                        id: "provider-session".to_owned(),
26135                        transcript_path: None,
26136                    },
26137                },
26138            },
26139        };
26140
26141        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26142
26143        let record = shared.record(&record_id).unwrap();
26144        assert!(record.provider_session.is_none());
26145        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26146        assert_eq!(record.active_session.as_ref(), Some(&address));
26147    }
26148
26149    #[test]
26150    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26151        let secondary_root = temporary_workspace_root("identity-scope");
26152        std::fs::create_dir_all(&secondary_root).unwrap();
26153        let catalog = active_registry().unwrap();
26154        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26155        let primary = WorkspaceConfig::new(
26156            WorkspaceId::new("primary").unwrap(),
26157            std::env::current_dir().unwrap(),
26158        )
26159        .unwrap();
26160        let secondary = WorkspaceConfig::new(
26161            WorkspaceId::new("secondary").unwrap(),
26162            &secondary_root,
26163        )
26164        .unwrap();
26165        let shared = NodeShared::new(
26166            handle,
26167            "fixture-token".to_owned(),
26168            NodeId::new("node-1").unwrap(),
26169            vec![primary.clone(), secondary.clone()],
26170            vec![agent("claude")],
26171        );
26172        let identity = gate4agent_types::ProviderSessionIdentity {
26173            key: gate4agent_types::ProviderSessionKey::SessionId,
26174            id: "provider-session".to_owned(),
26175            transcript_path: None,
26176        };
26177        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26178        shared
26179            .insert_record(ManagedSessionRecord {
26180                record_id: dormant_id.clone(),
26181                display_name: "dormant".to_owned(),
26182                provider: agent("claude"),
26183                mode: SessionMode::Pty,
26184                state: ManagedSessionState::Dormant,
26185                workspace_id: primary.workspace_id,
26186                canonical_root: opaque_windows_path(primary.canonical_root),
26187                provider_session: Some(identity.clone()),
26188                active_session: None,
26189                environment_profile: None,
26190                bundle: None,
26191                context_id: None,
26192                context: None,
26193                exported_context: None,
26194                task_binding: None,
26195                created_at_unix_ms: 1,
26196                updated_at_unix_ms: 1,
26197                last_error: None,
26198            })
26199            .unwrap();
26200        let address = SessionAddress {
26201            workspace_id: secondary.workspace_id.clone(),
26202            session: SessionKey {
26203                instance_id: AgentInstanceId(91),
26204                generation: SessionGeneration(1),
26205            },
26206        };
26207        let current_id = SessionRecordId::new("sr-current").unwrap();
26208        shared
26209            .insert_record(ManagedSessionRecord {
26210                record_id: current_id.clone(),
26211                display_name: "current".to_owned(),
26212                provider: agent("claude"),
26213                mode: SessionMode::Pty,
26214                state: ManagedSessionState::IdentityPending,
26215                workspace_id: secondary.workspace_id,
26216                canonical_root: opaque_windows_path(secondary.canonical_root),
26217                provider_session: None,
26218                active_session: Some(address.clone()),
26219                environment_profile: None,
26220                bundle: None,
26221                context_id: None,
26222                context: None,
26223                exported_context: None,
26224                task_binding: None,
26225                created_at_unix_ms: 2,
26226                updated_at_unix_ms: 2,
26227                last_error: None,
26228            })
26229            .unwrap();
26230        shared.bind_managed_session(
26231            &address,
26232            current_id.clone(),
26233            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26234            None,
26235        );
26236        let event = ControlEvent {
26237            sequence: 1,
26238            command_id: None,
26239            instance_id: address.session.instance_id,
26240            generation: address.session.generation,
26241            event: ControlEventKind::ProviderEvent {
26242                sequence: 1,
26243                source: gate4agent_types::ProviderSource {
26244                    family: gate4agent_types::AdapterFamily::PtySemantic,
26245                    binding: gate4agent_types::AdapterBinding::new(
26246                        gate4agent_types::AdapterId::new("claude").unwrap(),
26247                        "fixture/v1",
26248                        gate4agent_types::AdapterVerification::SyntheticFixture,
26249                    )
26250                    .unwrap(),
26251                },
26252                source_sequence: 1,
26253                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26254                    identity,
26255                },
26256            },
26257        };
26258        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26259
26260        let binding = shared
26261            .session_bindings
26262            .lock()
26263            .unwrap_or_else(|poisoned| poisoned.into_inner())
26264            .get(&address.session.instance_id)
26265            .cloned()
26266            .unwrap();
26267        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26268        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26269        let current = shared.record(&current_id).unwrap();
26270        assert_eq!(current.state, ManagedSessionState::Unavailable);
26271        assert_eq!(current.active_session.as_ref(), Some(&address));
26272        assert_eq!(
26273            current.last_error.as_deref(),
26274            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26275        );
26276        std::fs::remove_dir_all(secondary_root).unwrap();
26277    }
26278
26279    #[tokio::test]
26280    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26281        let root = temporary_workspace_root("semantic-provider-session");
26282        std::fs::create_dir_all(&root).unwrap();
26283        let state_path = root.join("state-v1.json");
26284        let catalog = active_registry().unwrap();
26285        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26286        let workspace = WorkspaceConfig::new(
26287            WorkspaceId::new("primary").unwrap(),
26288            &root,
26289        )
26290        .unwrap();
26291        let node_id = NodeId::new("node-semantic-identity").unwrap();
26292        let shared = NodeShared::new_with_incarnation(
26293            handle,
26294            "fixture-token".to_owned(),
26295            node_id.clone(),
26296            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26297            vec![workspace.clone()],
26298            vec![agent("claude")],
26299            ProviderRuntimeStatuses::default(),
26300            None,
26301            Vec::new(),
26302            Vec::new(),
26303            SpawnProfileRegistry::default(),
26304            None,
26305            None,
26306            None,
26307            Some(state_path.clone()),
26308            Vec::new(),
26309            Vec::new(),
26310            Vec::new(),
26311            Vec::new(),
26312            None,
26313            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26314        );
26315        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26316            key: gate4agent_types::ProviderSessionKey::SessionId,
26317            id: "provider-session".to_owned(),
26318            transcript_path: None,
26319        };
26320        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26321        shared
26322            .insert_record(ManagedSessionRecord {
26323                record_id: durable_id.clone(),
26324                display_name: "durable".to_owned(),
26325                provider: agent("claude"),
26326                mode: SessionMode::Pty,
26327                state: ManagedSessionState::Dormant,
26328                workspace_id: workspace.workspace_id.clone(),
26329                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26330                provider_session: Some(persisted_identity.clone()),
26331                active_session: None,
26332                environment_profile: None,
26333                bundle: None,
26334                context_id: None,
26335                context: None,
26336                exported_context: None,
26337                task_binding: None,
26338                created_at_unix_ms: 1,
26339                updated_at_unix_ms: 1,
26340                last_error: None,
26341            })
26342            .unwrap();
26343        let address = SessionAddress {
26344            workspace_id: workspace.workspace_id.clone(),
26345            session: SessionKey {
26346                instance_id: AgentInstanceId(92),
26347                generation: SessionGeneration(2),
26348            },
26349        };
26350        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26351        shared
26352            .insert_record(ManagedSessionRecord {
26353                record_id: pending_id.clone(),
26354                display_name: "pending".to_owned(),
26355                provider: agent("claude"),
26356                mode: SessionMode::Pty,
26357                state: ManagedSessionState::IdentityPending,
26358                workspace_id: workspace.workspace_id.clone(),
26359                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26360                provider_session: None,
26361                active_session: Some(address.clone()),
26362                environment_profile: None,
26363                bundle: None,
26364                context_id: None,
26365                context: None,
26366                exported_context: None,
26367                task_binding: None,
26368                created_at_unix_ms: 2,
26369                updated_at_unix_ms: 2,
26370                last_error: None,
26371            })
26372            .unwrap();
26373        shared.bind_managed_session(
26374            &address,
26375            pending_id.clone(),
26376            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26377            None,
26378        );
26379        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26380            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26381            ..persisted_identity
26382        };
26383        let event = ControlEvent {
26384            sequence: 1,
26385            command_id: None,
26386            instance_id: address.session.instance_id,
26387            generation: address.session.generation,
26388            event: ControlEventKind::ProviderEvent {
26389                sequence: 1,
26390                source: gate4agent_types::ProviderSource {
26391                    family: gate4agent_types::AdapterFamily::PtySemantic,
26392                    binding: gate4agent_types::AdapterBinding::new(
26393                        gate4agent_types::AdapterId::new("claude").unwrap(),
26394                        "fixture/v1",
26395                        gate4agent_types::AdapterVerification::SyntheticFixture,
26396                    )
26397                    .unwrap(),
26398                },
26399                source_sequence: 1,
26400                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26401                    identity: observed_identity.clone(),
26402                },
26403            },
26404        };
26405        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26406
26407        let snapshot = shared.snapshot();
26408        assert_eq!(snapshot.session_records.len(), 1);
26409        let live = &snapshot.session_records[0];
26410        assert_eq!(live.record_id, durable_id);
26411        assert_eq!(live.state, ManagedSessionState::Live);
26412        assert_eq!(live.active_session.as_ref(), Some(&address));
26413        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26414        let binding = shared
26415            .session_bindings
26416            .lock()
26417            .unwrap_or_else(|poisoned| poisoned.into_inner())
26418            .get(&address.session.instance_id)
26419            .cloned()
26420            .unwrap();
26421        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26422
26423        let mut duplicate = live.clone();
26424        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26425        duplicate.display_name = "duplicate".to_owned();
26426        duplicate.state = ManagedSessionState::Dormant;
26427        duplicate.active_session = None;
26428        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26429        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26430        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26431        assert_eq!(shared.snapshot().session_records.len(), 1);
26432
26433        let terminal_size = gate4agent_types::TerminalSize {
26434            rows: 24,
26435            columns: 80,
26436        };
26437        let (first_busy, second_busy) = tokio::join!(
26438            shared.resume_session_record(&durable_id, terminal_size, None),
26439            shared.resume_session_record(&durable_id, terminal_size, None),
26440        );
26441        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26442            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26443            assert_eq!(busy.message, "session-record-busy");
26444        }
26445
26446        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26447        assert_eq!(loaded.records.len(), 1);
26448        assert_eq!(loaded.records[0].record_id, durable_id);
26449        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26450        assert_eq!(
26451            loaded.records[0]
26452                .provider_session
26453                .as_ref()
26454                .unwrap()
26455                .transcript_path,
26456            None,
26457        );
26458        let catalog = active_registry().unwrap();
26459        let (reloaded_handle, _reloaded_runtime) =
26460            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26461        let reloaded = NodeShared::new_with_incarnation(
26462            reloaded_handle,
26463            "fixture-token".to_owned(),
26464            node_id,
26465            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26466            vec![workspace],
26467            vec![agent("claude")],
26468            ProviderRuntimeStatuses::default(),
26469            None,
26470            Vec::new(),
26471            Vec::new(),
26472            SpawnProfileRegistry::default(),
26473            None,
26474            None,
26475            None,
26476            None,
26477            loaded.records,
26478            loaded.managed_worktrees,
26479            loaded.managed_worktree_tombstones,
26480            loaded.materializations,
26481            loaded.warning,
26482            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26483        );
26484        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26485        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26486        std::fs::remove_dir_all(root).unwrap();
26487    }
26488
26489    #[tokio::test]
26490    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26491        let secondary_root = temporary_workspace_root("workspace-restore");
26492        let other_root = temporary_workspace_root("workspace-conflict");
26493        std::fs::create_dir_all(&secondary_root).unwrap();
26494        std::fs::create_dir_all(&other_root).unwrap();
26495        let catalog = active_registry().unwrap();
26496        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26497        let primary = WorkspaceConfig::new(
26498            WorkspaceId::new("primary").unwrap(),
26499            std::env::current_dir().unwrap(),
26500        )
26501        .unwrap();
26502        let secondary = WorkspaceConfig::new(
26503            WorkspaceId::new("secondary").unwrap(),
26504            &secondary_root,
26505        )
26506        .unwrap();
26507        let shared = NodeShared::new(
26508            handle,
26509            "fixture-token".to_owned(),
26510            NodeId::new("node-1").unwrap(),
26511            vec![primary, secondary.clone()],
26512            vec![agent("claude")],
26513        );
26514        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26515        shared
26516            .insert_record(ManagedSessionRecord {
26517                record_id: record_id.clone(),
26518                display_name: "workspace record".to_owned(),
26519                provider: agent("claude"),
26520                mode: SessionMode::Pty,
26521                state: ManagedSessionState::Dormant,
26522                workspace_id: secondary.workspace_id.clone(),
26523                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26524                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26525                    key: gate4agent_types::ProviderSessionKey::SessionId,
26526                    id: "provider-session".to_owned(),
26527                    transcript_path: None,
26528                }),
26529                active_session: None,
26530                environment_profile: None,
26531                bundle: None,
26532                context_id: None,
26533                context: None,
26534                exported_context: None,
26535                task_binding: None,
26536                created_at_unix_ms: 1,
26537                updated_at_unix_ms: 1,
26538                last_error: None,
26539            })
26540            .unwrap();
26541        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26542        let unavailable = shared.record(&record_id).unwrap();
26543        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26544        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26545
26546        let conflict = shared
26547            .register_workspace(
26548                secondary.workspace_id.clone(),
26549                other_root.to_string_lossy().into_owned(),
26550            )
26551            .await
26552            .unwrap_err();
26553        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26554        shared
26555            .register_workspace(
26556                secondary.workspace_id,
26557                secondary_root.to_string_lossy().into_owned(),
26558            )
26559            .await
26560            .unwrap();
26561        let restored = shared.record(&record_id).unwrap();
26562        assert_eq!(restored.state, ManagedSessionState::Dormant);
26563        assert!(restored.last_error.is_none());
26564        std::fs::remove_dir_all(secondary_root).unwrap();
26565        std::fs::remove_dir_all(other_root).unwrap();
26566    }
26567
26568    #[test]
26569    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26570        let error = validate_workspace_request_root(
26571            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26572        )
26573        .unwrap_err();
26574        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26575        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26576        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26577    }
26578
26579    #[test]
26580    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26581        let catalog = active_registry().unwrap();
26582        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26583        let workspace = WorkspaceConfig::new(
26584            WorkspaceId::new("primary").unwrap(),
26585            std::env::current_dir().unwrap(),
26586        )
26587        .unwrap();
26588        let shared = NodeShared::new(
26589            handle,
26590            "fixture-token".to_owned(),
26591            NodeId::new("node-1").unwrap(),
26592            vec![workspace],
26593            vec![agent("claude")],
26594        );
26595        let publishers = 4_u64;
26596        let events_per_publisher = 256_u64;
26597        let start = std::sync::Barrier::new(publishers as usize + 1);
26598        let finished = AtomicU64::new(0);
26599
26600        std::thread::scope(|scope| {
26601            for _ in 0..publishers {
26602                scope.spawn(|| {
26603                    start.wait();
26604                    for _ in 0..events_per_publisher {
26605                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26606                    }
26607                    finished.fetch_add(1, Ordering::Release);
26608                });
26609            }
26610            start.wait();
26611            while finished.load(Ordering::Acquire) != publishers {
26612                assert_resync_history_is_linearized(shared.resync(0));
26613                std::thread::yield_now();
26614            }
26615        });
26616        let response = shared.resync(0);
26617        assert_resync_history_is_linearized(response.clone());
26618        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26619            unreachable!("resync helper returned another response");
26620        };
26621        assert_eq!(event_sequence, publishers * events_per_publisher);
26622        assert_eq!(events.len() as u64, event_sequence);
26623    }
26624
26625    fn assert_resync_history_is_linearized(response: NodeResponse) {
26626        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26627            unreachable!("resync helper returned another response");
26628        };
26629        assert!(events.windows(2).all(|pair| {
26630            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26631        }));
26632        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26633    }
26634
26635    #[test]
26636    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26637        assert_eq!(
26638            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26639            r"C:\repo\worktree",
26640        );
26641        assert_eq!(
26642            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26643            r"\\server\share\repo",
26644        );
26645        assert!(validate_workspace_root(
26646            &WorkspaceId::new("network").unwrap(),
26647            r"\\server\share\repo",
26648        )
26649        .is_err());
26650        let workspace = WorkspaceConfig::new(
26651            WorkspaceId::new("primary").unwrap(),
26652            std::env::current_dir().unwrap(),
26653        )
26654        .unwrap();
26655        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26656    }
26657
26658    #[test]
26659    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26660        let catalog = active_registry().unwrap();
26661        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26662        let workspace_id = WorkspaceId::new("primary").unwrap();
26663        let workspace = WorkspaceConfig::new(
26664            workspace_id.clone(),
26665            std::env::current_dir().unwrap(),
26666        )
26667        .unwrap();
26668        let shared = NodeShared::new(
26669            handle,
26670            "fixture-token".to_owned(),
26671            NodeId::new("node-1").unwrap(),
26672            vec![workspace],
26673            vec![agent("claude")],
26674        );
26675        let instance_id = AgentInstanceId(17);
26676        let original = SessionAddress {
26677            workspace_id,
26678            session: SessionKey {
26679                instance_id,
26680                generation: SessionGeneration(3),
26681            },
26682        };
26683        shared.bind_session(
26684            instance_id,
26685            original.workspace_id.clone(),
26686            original.session.generation,
26687        );
26688        shared
26689            .arm_resume(
26690                &original,
26691                CommandId(41),
26692                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26693            )
26694            .unwrap();
26695
26696        shared.publish_control(ControlEvent {
26697            sequence: 1,
26698            command_id: None,
26699            instance_id,
26700            generation: SessionGeneration(4),
26701            event: ControlEventKind::Running { process_id: None },
26702        });
26703        assert_eq!(
26704            shared.address_for(instance_id, SessionGeneration(3)),
26705            Some(original.clone()),
26706        );
26707        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26708
26709        shared.publish_control(ControlEvent {
26710            sequence: 2,
26711            command_id: Some(CommandId(42)),
26712            instance_id,
26713            generation: SessionGeneration(3),
26714            event: ControlEventKind::CommandRejected {
26715                message: "unrelated command".to_owned(),
26716            },
26717        });
26718
26719        shared.publish_control(ControlEvent {
26720            sequence: 3,
26721            command_id: None,
26722            instance_id,
26723            generation: SessionGeneration(4),
26724            event: ControlEventKind::ResumeAuthorized {
26725                session: gate4agent_types::ResumeSessionSummary {
26726                    key: gate4agent_types::ProviderSessionKey::SessionId,
26727                    id: "fixture-session".to_owned(),
26728                },
26729            },
26730        });
26731        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26732        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26733        assert_eq!(rebound.workspace_id, original.workspace_id);
26734        let history = shared
26735            .history
26736            .lock()
26737            .unwrap_or_else(|poisoned| poisoned.into_inner());
26738        assert!(history.events.iter().any(|envelope| matches!(
26739            &envelope.event,
26740            NodeEvent::Control { address, event }
26741                if address == &rebound
26742                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26743        )));
26744    }
26745
26746    #[test]
26747    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26748        for agent in ["claude", "codex", "kimi"] {
26749            assert_eq!(
26750                prompt_framing(&AgentId::new(agent).unwrap()),
26751                PromptFraming::BracketedPaste
26752            );
26753        }
26754    }
26755
26756    fn generous_walk_deadline() -> Instant {
26757        Instant::now() + Duration::from_secs(30)
26758    }
26759
26760    #[test]
26761    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26762        let root = temporary_workspace_root("tree");
26763        std::fs::create_dir_all(root.join("src")).unwrap();
26764        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26765        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26766        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26767        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26768        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26769        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26770        // real repository's own build layout) that carries the Cache
26771        // Directory Tagging Specification marker cargo writes into every
26772        // `--target-dir` it creates: must be skipped even though its name
26773        // does not exactly match `target`.
26774        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26775        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26776        // A directory that merely *starts with* `target` but carries no
26777        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26778        // on the name alone, so this stays in the response.
26779        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26780        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26781
26782        let (entries, truncated, _budget) =
26783            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26784        assert!(!truncated);
26785        assert!(entries.iter().any(|entry| {
26786            entry.relative_path.as_utf8() == Some("src")
26787                && entry.kind == WorkspaceEntryKind::Directory
26788        }));
26789        assert!(entries.iter().any(|entry| {
26790            entry.relative_path.as_utf8() == Some("src/lib.rs")
26791                && entry.kind == WorkspaceEntryKind::File
26792        }));
26793        assert!(entries.iter().any(|entry| {
26794            entry.relative_path.as_utf8() == Some("target-notes")
26795                && entry.kind == WorkspaceEntryKind::Directory
26796        }));
26797        assert!(entries.iter().any(|entry| {
26798            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26799                && entry.kind == WorkspaceEntryKind::File
26800        }));
26801        assert!(entries.iter().all(|entry| {
26802            let path = entry.relative_path.as_utf8().unwrap();
26803            !path.starts_with(".git")
26804                && !(path == "target" || path.starts_with("target/"))
26805                && !(path == "target-a2" || path.starts_with("target-a2/"))
26806                && !path.starts_with("node_modules")
26807        }));
26808
26809        std::fs::remove_dir_all(&root).unwrap();
26810    }
26811
26812    #[test]
26813    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26814        let root = temporary_workspace_root("tree-entry-cap");
26815        std::fs::create_dir_all(&root).unwrap();
26816        for index in 0..20 {
26817            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26818        }
26819
26820        let (entries, truncated, budget) =
26821            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26822        assert!(truncated);
26823        assert!(budget.entry_cap_exceeded);
26824        assert!(!budget.time_budget_exceeded);
26825        assert!(entries.len() <= 5);
26826
26827        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26828            .expect("an entry-cap-truncated walk must report a truncation marker");
26829        assert!(inspection_truncation.walk_entry_cap_exceeded);
26830        assert!(!inspection_truncation.walk_time_budget_exceeded);
26831        assert!(!inspection_truncation.git_time_budget_exceeded);
26832        assert!(inspection_truncation.entries_visited >= 5);
26833
26834        std::fs::remove_dir_all(&root).unwrap();
26835    }
26836
26837    #[test]
26838    fn workspace_walk_within_budget_reports_no_truncation() {
26839        let root = temporary_workspace_root("tree-no-truncation");
26840        std::fs::create_dir_all(&root).unwrap();
26841        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26842
26843        let (_entries, truncated, budget) =
26844            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26845        assert!(!truncated);
26846        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26847
26848        std::fs::remove_dir_all(&root).unwrap();
26849    }
26850
26851    #[test]
26852    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26853        let root = temporary_workspace_root("tree-prefix-siblings");
26854        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26855        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26856        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26857        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26858
26859        let (entries, truncated, _budget) =
26860            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26861        assert!(!truncated);
26862        let paths = entries
26863            .iter()
26864            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26865            .collect::<Vec<_>>();
26866        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26867        assert_eq!(
26868            paths,
26869            vec![
26870                "gate4agent-c2".to_owned(),
26871                "gate4agent-c2-client".to_owned(),
26872                "gate4agent-c2-client/lib.rs".to_owned(),
26873                "gate4agent-c2/src".to_owned(),
26874                "gate4agent-c2/src/lib.rs".to_owned(),
26875            ],
26876        );
26877
26878        std::fs::remove_dir_all(&root).unwrap();
26879    }
26880
26881    #[test]
26882    fn git_parsers_cap_status_and_commit_results() {
26883        let mut status_output = Vec::new();
26884        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26885            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26886        }
26887        let mut snapshot = git_snapshot_for_parser();
26888        parse_git_status(&status_output, &mut snapshot);
26889        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26890        assert!(snapshot.truncated);
26891        assert_eq!(snapshot.status[0].index_status, " ");
26892        assert_eq!(snapshot.status[0].worktree_status, "M");
26893
26894        snapshot.truncated = false;
26895        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26896            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26897            .collect::<String>();
26898        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26899        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26900        assert!(snapshot.truncated);
26901    }
26902
26903    #[test]
26904    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26905        let mut snapshot = git_snapshot_for_parser();
26906        parse_git_status(
26907            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26908            &mut snapshot,
26909        );
26910
26911        assert!(!snapshot.truncated);
26912        assert_eq!(snapshot.status.len(), 3);
26913        assert_eq!(snapshot.status[0].index_status, "R");
26914        assert_eq!(snapshot.status[0].worktree_status, " ");
26915        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26916        assert_eq!(
26917            snapshot.status[0]
26918                .previous_path
26919                .as_ref()
26920                .and_then(RepositoryPath::as_utf8),
26921            Some("src/previous name.rs"),
26922        );
26923        assert_eq!(snapshot.status[1].index_status, " ");
26924        assert_eq!(snapshot.status[1].worktree_status, "C");
26925        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26926        assert_eq!(
26927            snapshot.status[1]
26928                .previous_path
26929                .as_ref()
26930                .and_then(RepositoryPath::as_utf8),
26931            Some("src/original.rs"),
26932        );
26933        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26934        assert!(snapshot.status[2].previous_path.is_none());
26935    }
26936
26937    #[test]
26938    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26939        let mut snapshot = git_snapshot_for_parser();
26940        parse_git_status(
26941            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26942            &mut snapshot,
26943        );
26944
26945        assert!(snapshot.truncated);
26946        assert_eq!(snapshot.status.len(), 1);
26947        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26948        assert!(snapshot.status[0].previous_path.is_none());
26949    }
26950
26951    #[tokio::test]
26952    async fn observer_can_browse_host_directories_without_controller() {
26953        let root = temporary_workspace_root("host-directory-observer");
26954        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26955        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26956        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26957        let workspace_id = WorkspaceId::new("primary").unwrap();
26958        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26959        let catalog = active_registry().unwrap();
26960        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26961        let shared = NodeShared::new(
26962            handle,
26963            "fixture-token".to_owned(),
26964            NodeId::new("node-1").unwrap(),
26965            vec![workspace],
26966            vec![agent("claude")],
26967        );
26968        let request = NodeRequest::BrowseHostDirectories {
26969            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26970            after: None,
26971        };
26972        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26973            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26974        }));
26975        assert!(!request_uses_unnegotiated_capability(
26976            &request,
26977            &baseline_capabilities().unwrap(),
26978        ));
26979        assert!(request_uses_unnegotiated_capability(&request, &[]));
26980
26981        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26982            .await
26983            .unwrap();
26984        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26985            panic!("host directory browse returned another response");
26986        };
26987        assert_eq!(
26988            listing
26989                .entries
26990                .iter()
26991                .map(|entry| entry.display_name.as_str())
26992                .collect::<Vec<_>>(),
26993            vec!["directory-a", "directory-b"],
26994        );
26995        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26996        assert!(!listing.incomplete);
26997        std::fs::remove_dir_all(root).unwrap();
26998    }
26999
27000    #[tokio::test]
27001    async fn observer_can_inspect_only_a_registered_workspace() {
27002        let root = temporary_workspace_root("observer");
27003        std::fs::create_dir_all(root.join("src")).unwrap();
27004        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27005        let workspace_id = WorkspaceId::new("primary").unwrap();
27006        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27007        let catalog = active_registry().unwrap();
27008        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27009        let shared = NodeShared::new(
27010            handle,
27011            "fixture-token".to_owned(),
27012            NodeId::new("node-1").unwrap(),
27013            vec![workspace],
27014            vec![agent("claude")],
27015        );
27016
27017        let response = process_request_inner(
27018            &shared,
27019            77,
27020            ClientRole::Observer,
27021            NodeRequest::InspectWorkspace {
27022                workspace_id: workspace_id.clone(),
27023            },
27024        )
27025        .await
27026        .unwrap();
27027        let NodeResponse::WorkspaceInspected { inspection } = response else {
27028            panic!("workspace inspection returned another response");
27029        };
27030        assert_eq!(inspection.workspace_id, workspace_id);
27031        assert!(inspection.entries.iter().any(|entry| {
27032            entry.relative_path.as_utf8() == Some("src/main.rs")
27033        }));
27034
27035        let unknown = process_request_inner(
27036            &shared,
27037            77,
27038            ClientRole::Observer,
27039            NodeRequest::InspectWorkspace {
27040                workspace_id: WorkspaceId::new("unknown").unwrap(),
27041            },
27042        )
27043        .await
27044        .unwrap_err();
27045        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27046        std::fs::remove_dir_all(&root).unwrap();
27047    }
27048
27049    #[test]
27050    fn context_pack_source_accepts_only_running_or_successful_exit() {
27051        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27052        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27053            exit_code: Some(0),
27054        }));
27055        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27056            exit_code: Some(1),
27057        }));
27058        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27059            exit_code: None,
27060        }));
27061        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27062            message: "provider failed".to_owned(),
27063        }));
27064        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27065        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27066        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27067    }
27068
27069    #[test]
27070    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27071        let git = git_snapshot_for_parser();
27072        let first = ContextPackRepositoryObservation {
27073            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27074            git: git.clone(),
27075            files: Vec::new(),
27076        };
27077        let second = ContextPackRepositoryObservation {
27078            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27079            git,
27080            files: Vec::new(),
27081        };
27082
27083        let error = stable_context_pack_repository(first, second).unwrap_err();
27084
27085        assert_eq!(
27086            error.code,
27087            NodeFailureCode::ContextPackMaterializationFailed,
27088        );
27089    }
27090
27091    #[test]
27092    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27093        let git = git_snapshot_for_parser();
27094        let first = ContextPackRepositoryObservation {
27095            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27096            git: git.clone(),
27097            files: vec![ContextPackRepositoryFileSource::utf8(
27098                "README.md",
27099                "first\n".to_owned(),
27100                6,
27101            )],
27102        };
27103        let second = ContextPackRepositoryObservation {
27104            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27105            git,
27106            files: vec![ContextPackRepositoryFileSource::utf8(
27107                "README.md",
27108                "other\n".to_owned(),
27109                6,
27110            )],
27111        };
27112
27113        let error = stable_context_pack_repository(first, second).unwrap_err();
27114
27115        assert_eq!(
27116            error.code,
27117            NodeFailureCode::ContextPackMaterializationFailed,
27118        );
27119    }
27120
27121    #[tokio::test]
27122    async fn native_session_catalog_zero_active_session_fixture() {
27123        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27124        let history_root = temporary_workspace_root("native-catalog-history");
27125        let outside_root = temporary_workspace_root("native-catalog-outside");
27126        std::fs::create_dir_all(&workspace_root).unwrap();
27127        std::fs::create_dir_all(&history_root).unwrap();
27128        std::fs::create_dir_all(&outside_root).unwrap();
27129        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27130            [
27131                serde_json::json!({
27132                    "type": "user",
27133                    "sessionId": session_id,
27134                    "cwd": cwd.to_str().unwrap(),
27135                    "message": { "content": "raw user text must not cross the catalog wire" }
27136                }),
27137                serde_json::json!({
27138                    "type": "assistant",
27139                    "sessionId": session_id,
27140                    "cwd": cwd.to_str().unwrap(),
27141                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27142                }),
27143                serde_json::json!({
27144                    "type": "ai-title",
27145                    "sessionId": session_id,
27146                    "aiTitle": title
27147                }),
27148            ]
27149            .into_iter()
27150            .map(|record| record.to_string())
27151            .collect::<Vec<_>>()
27152            .join("\n")
27153        };
27154        std::fs::write(
27155            history_root.join("stable-session.jsonl"),
27156            transcript("stable-session", &workspace_root, "Workspace review"),
27157        )
27158        .unwrap();
27159        std::fs::write(
27160            history_root.join("outside-session.jsonl"),
27161            transcript("outside-session", &outside_root, "Outside review"),
27162        )
27163        .unwrap();
27164        let history = NativeHistoryConfig::new(vec![
27165            NativeHistoryRoot::new(
27166                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27167                HistorySourceLayout::SingleNdjson,
27168                history_root.clone(),
27169            )
27170            .unwrap(),
27171        ])
27172        .unwrap();
27173        let catalog = active_registry().unwrap();
27174        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27175        assert!(handle.snapshot().sessions.is_empty());
27176        let workspace_id = WorkspaceId::new("primary").unwrap();
27177        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27178        let mut shared = NodeShared::new(
27179            handle,
27180            "fixture-token".to_owned(),
27181            NodeId::new("node-native-catalog").unwrap(),
27182            vec![workspace],
27183            vec![agent("claude")],
27184        );
27185        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27186            NativeSessionCatalogAuthority::new(history),
27187        )));
27188        let route = NativeSessionCatalogRoute::workspace(
27189            workspace_id.clone(),
27190            agent("claude"),
27191        );
27192        let request = NodeRequest::CatalogNativeSessions {
27193            route: route.clone(),
27194            limit: 2,
27195        };
27196        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27197            .await
27198            .unwrap_err();
27199        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27200        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27201            .await
27202            .unwrap();
27203        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27204            panic!("native catalog returned a different response");
27205        };
27206        let summary = summary.expect("node must emit native session catalog summary metadata");
27207        assert_eq!(entries.len(), 1);
27208        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27209        assert_eq!(entries[0].external_group, None);
27210        assert_eq!(entries[0].record_id, None);
27211        assert_eq!(entries[0].title, None);
27212        assert_eq!(entries[0].model, None);
27213        assert_eq!(entries[0].message_count, 0);
27214        let encoded_entries = serde_json::to_string(&entries).unwrap();
27215        assert!(!encoded_entries.contains("stable-session"));
27216        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27217        let page_request = NodeRequest::PageNativeSessions {
27218            route: route.clone(),
27219            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27220            catalog_revision: summary.catalog_revision,
27221            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27222            after_selection_id: None,
27223            limit: 2,
27224        };
27225        let observer = process_request_inner(
27226            &shared,
27227            7,
27228            ClientRole::Observer,
27229            page_request.clone(),
27230        )
27231        .await
27232        .unwrap_err();
27233        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27234        let page_response = process_request_inner(
27235            &shared,
27236            7,
27237            ClientRole::Operator,
27238            page_request.clone(),
27239        )
27240        .await
27241        .unwrap();
27242        assert!(matches!(
27243            page_response,
27244            NodeResponse::NativeSessionsPaged { page, .. }
27245                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27246        ));
27247        let stale = process_request_inner(
27248            &shared,
27249            7,
27250            ClientRole::Operator,
27251            NodeRequest::PageNativeSessions {
27252                route: route.clone(),
27253                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27254                catalog_revision: summary.catalog_revision.wrapping_add(1),
27255                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27256                after_selection_id: None,
27257                limit: 2,
27258            },
27259        )
27260        .await
27261        .unwrap_err();
27262        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27263        let selection = NativeSessionSelection {
27264            route: route.clone(),
27265            catalog_revision: summary.catalog_revision,
27266            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27267            selection_id: entries[0].selection_id.clone(),
27268        };
27269        let preview_response = process_request_inner(
27270            &shared,
27271            7,
27272            ClientRole::Operator,
27273            NodeRequest::PreviewNativeSession {
27274                selection: selection.clone(),
27275                message_limit: 1,
27276            },
27277        )
27278        .await
27279        .unwrap();
27280        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27281            panic!("native preview returned a different response");
27282        };
27283        assert_eq!(preview.messages.len(), 1);
27284        assert!(preview.message_count_exact);
27285        assert_eq!(preview.messages[0].text, "raw answer");
27286        assert!(preview.truncated);
27287
27288        let index_request = NodeRequest::IndexNativeSession {
27289            selection: selection.clone(),
27290            display_name: "Saved Claude".to_owned(),
27291        };
27292        let observer = process_request_inner(
27293            &shared,
27294            7,
27295            ClientRole::Observer,
27296            index_request.clone(),
27297        )
27298        .await
27299        .unwrap_err();
27300        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27301        let without_controller = process_request_inner(
27302            &shared,
27303            7,
27304            ClientRole::Operator,
27305            index_request.clone(),
27306        )
27307        .await
27308        .unwrap_err();
27309        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27310        let acquired = process_request_inner(
27311            &shared,
27312            7,
27313            ClientRole::Operator,
27314            NodeRequest::AcquireController { lease_ms: 5_000 },
27315        )
27316        .await
27317        .unwrap();
27318        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27319        let indexed = process_request_inner(
27320            &shared,
27321            7,
27322            ClientRole::Operator,
27323            index_request,
27324        )
27325        .await
27326        .unwrap();
27327        assert!(indexed.native_session_index_contract_is_valid());
27328        let NodeResponse::NativeSessionIndexed {
27329            selection: echoed_selection,
27330            record,
27331        } = indexed else {
27332            panic!("native session index returned a different response");
27333        };
27334        assert_eq!(echoed_selection, selection);
27335        let recataloged = process_request_inner(
27336            &shared,
27337            7,
27338            ClientRole::Operator,
27339            NodeRequest::CatalogNativeSessions {
27340                route,
27341                limit: 2,
27342            },
27343        )
27344        .await
27345        .unwrap();
27346        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27347            panic!("native catalog returned a different response after indexing");
27348        };
27349        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27350
27351        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27352        let external_catalog = process_request_inner(
27353            &shared,
27354            7,
27355            ClientRole::Operator,
27356            NodeRequest::CatalogNativeSessions {
27357                route: unregistered_route.clone(),
27358                limit: 2,
27359            },
27360        )
27361        .await
27362        .unwrap();
27363        let external_json = serde_json::to_string(&external_catalog).unwrap();
27364        assert!(!external_json.contains("outside-session"));
27365        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27366        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27367        assert!(!external_json.contains("\"session_id\""));
27368        assert!(!external_json.contains("\"cwd\""));
27369        assert!(!external_json.contains("\"path\""));
27370        let NodeResponse::NativeSessionsCataloged {
27371            route: echoed_route,
27372            entries: external_entries,
27373            summary: Some(external_summary),
27374        } = external_catalog
27375        else {
27376            panic!("unregistered native catalog returned a different response");
27377        };
27378        assert_eq!(echoed_route, unregistered_route);
27379        assert_eq!(external_entries.len(), 1);
27380        assert_eq!(external_entries[0].record_id, None);
27381        let external_group = external_entries[0]
27382            .external_group
27383            .as_ref()
27384            .expect("unregistered session must have an external group");
27385        assert_eq!(
27386            external_group.kind,
27387            gate4agent_types::NativeSessionExternalGroupKind::Project,
27388        );
27389        assert!(external_group.group_id.starts_with("external-"));
27390        assert_eq!(
27391            external_group.display_name,
27392            outside_root.file_name().unwrap().to_string_lossy(),
27393        );
27394        let external_selection = NativeSessionSelection {
27395            route: unregistered_route,
27396            catalog_revision: external_summary.catalog_revision,
27397            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27398            selection_id: external_entries[0].selection_id.clone(),
27399        };
27400        let external_preview = process_request_inner(
27401            &shared,
27402            7,
27403            ClientRole::Operator,
27404            NodeRequest::PreviewNativeSession {
27405                selection: external_selection.clone(),
27406                message_limit: 1,
27407            },
27408        )
27409        .await
27410        .unwrap();
27411        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27412        assert!(!external_preview_json.contains("outside-session"));
27413        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27414        assert!(!external_preview_json.contains("\"session_id\""));
27415        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27416            panic!("unregistered native preview returned a different response");
27417        };
27418        assert_eq!(preview.messages.len(), 1);
27419        let external_index = process_request_inner(
27420            &shared,
27421            7,
27422            ClientRole::Operator,
27423            NodeRequest::IndexNativeSession {
27424                selection: external_selection,
27425                display_name: "External Claude".to_owned(),
27426            },
27427        )
27428        .await
27429        .unwrap_err();
27430        assert_eq!(
27431            external_index.code,
27432            NodeFailureCode::WorkspaceRegistrationRequired,
27433        );
27434        let record_response = process_request_inner(
27435            &shared,
27436            7,
27437            ClientRole::Operator,
27438            NodeRequest::PreviewSessionRecord {
27439                record_id: record.record_id.clone(),
27440                message_limit: 2,
27441            },
27442        )
27443        .await
27444        .unwrap();
27445        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27446            panic!("record preview returned a different response");
27447        };
27448        assert_eq!(record_id, record.record_id);
27449        assert_eq!(preview.messages.len(), 2);
27450        let encoded = serde_json::to_string(&preview).unwrap();
27451        assert!(!encoded.contains("stable-session"));
27452        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27453        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27454            panic!("record preview history summary returned a different response");
27455        };
27456        let summaries = events
27457            .iter()
27458            .filter_map(|envelope| match &envelope.event {
27459                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27460                    if record_id == &record.record_id =>
27461                {
27462                    Some(*summary)
27463                }
27464                _ => None,
27465            })
27466            .collect::<Vec<_>>();
27467        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27468        let summary_json = serde_json::to_string(&summaries).unwrap();
27469        for private in [
27470            "stable-session",
27471            "raw user text must not cross the catalog wire",
27472            "raw answer",
27473            "Workspace review",
27474            "claude-sonnet",
27475        ] {
27476            assert!(!summary_json.contains(private), "{private}");
27477        }
27478        let identity = record.provider_session.clone().unwrap();
27479        assert_eq!(
27480            shared.revalidate_session_record_preview(&record, &identity),
27481            Ok(())
27482        );
27483        shared.remove_record_memory(&record.record_id);
27484        assert_eq!(
27485            shared
27486                .revalidate_session_record_preview(&record, &identity)
27487                .unwrap_err()
27488                .code,
27489            NodeFailureCode::UnknownSessionRecord
27490        );
27491        let released = process_request_inner(
27492            &shared,
27493            7,
27494            ClientRole::Operator,
27495            NodeRequest::ReleaseController,
27496        )
27497        .await
27498        .unwrap();
27499        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27500        assert!(shared.controller_state().is_none());
27501        std::fs::remove_dir_all(workspace_root).unwrap();
27502        std::fs::remove_dir_all(history_root).unwrap();
27503        std::fs::remove_dir_all(outside_root).unwrap();
27504    }
27505
27506    #[tokio::test]
27507    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27508        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27509        std::fs::create_dir_all(&history_root).unwrap();
27510        let history = NativeHistoryConfig::new(vec![
27511            NativeHistoryRoot::new(
27512                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27513                HistorySourceLayout::SingleNdjson,
27514                history_root.clone(),
27515            )
27516            .unwrap(),
27517        ])
27518        .unwrap();
27519        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27520        let held = catalog.lock().unwrap();
27521        let result = timeout(
27522            Duration::from_secs(1),
27523            run_native_session_catalog_operation(
27524                Arc::clone(&catalog),
27525                "native session catalog",
27526                |_| Ok::<(), std::convert::Infallible>(()),
27527            ),
27528        )
27529        .await
27530        .expect("concurrent native catalog request must not wait for the operation deadline")
27531        .unwrap_err();
27532        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27533        drop(held);
27534        std::fs::remove_dir_all(history_root).unwrap();
27535    }
27536
27537    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27538        let unique = std::time::SystemTime::now()
27539            .duration_since(std::time::UNIX_EPOCH)
27540            .unwrap()
27541            .as_nanos();
27542        std::env::temp_dir().join(format!(
27543            "gate4agent-node-{label}-{}-{unique}",
27544            std::process::id(),
27545        ))
27546    }
27547
27548    fn git_snapshot_for_parser() -> GitSnapshot {
27549        GitSnapshot {
27550            is_repository: true,
27551            branch: Some("main".to_owned()),
27552            status: Vec::new(),
27553            recent_commits: Vec::new(),
27554            worktrees: Vec::new(),
27555            managed_worktree: None,
27556            truncated: false,
27557            diagnostic: None,
27558        }
27559    }
27560
27561    #[tokio::test]
27562    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27563        let root = temporary_workspace_root("managed-git-scope");
27564        std::fs::create_dir_all(&root).unwrap();
27565        run_test_git(&root, &["init"]);
27566        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27567        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27568        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27569        run_test_git(&root, &["add", "seed.txt"]);
27570        run_test_git(&root, &["commit", "-m", "seed"]);
27571        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27572
27573        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27574        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27575        let catalog = active_registry().unwrap();
27576        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27577        let shared = NodeShared::new(
27578            handle,
27579            "fixture-token".to_owned(),
27580            NodeId::new("node-1").unwrap(),
27581            vec![workspace],
27582            vec![agent("claude")],
27583        );
27584        let mut lease = ManagedWorktreeLeaseRecord {
27585            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27586            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27587            workspace_id: workspace_id.clone(),
27588            profile_id: WorktreeProfileId::new("default").unwrap(),
27589            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27590            target_root: root.to_string_lossy().into_owned(),
27591            branch: "gate4agent/a".to_owned(),
27592            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27593            expected_head: None,
27594            retention: ManagedWorktreeRetention::Retain,
27595            state: ManagedWorktreeLeaseState::InUse,
27596            session_holders: vec![ManagedWorktreeSessionHolder {
27597                incarnation_id: shared.incarnation_id,
27598                instance_id: AgentInstanceId(7),
27599                generation: SessionGeneration(1),
27600            }],
27601            record_holders: Vec::new(),
27602            cleanup_failure: None,
27603            created_at_unix_ms: 1,
27604            updated_at_unix_ms: 1,
27605        };
27606        *shared
27607            .managed_worktrees
27608            .lock()
27609            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27610            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27611
27612        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27613        let scope = inspection.git.managed_worktree.unwrap();
27614        assert_eq!(scope.lease_id, lease.lease_id);
27615        assert_eq!(scope.branch, "gate4agent/a");
27616        assert_eq!(scope.active_session_count, 1);
27617
27618        lease.branch = "gate4agent/b".to_owned();
27619        *shared
27620            .managed_worktrees
27621            .lock()
27622            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27623            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27624        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27625        assert!(mismatch.git.managed_worktree.is_none());
27626        std::fs::remove_dir_all(root).unwrap();
27627    }
27628
27629    #[tokio::test]
27630    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27631        let root = temporary_workspace_root("workspace-file-write-controller");
27632        std::fs::create_dir_all(root.join("src")).unwrap();
27633        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27634        let workspace_id = WorkspaceId::new("primary").unwrap();
27635        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27636        let catalog = active_registry().unwrap();
27637        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27638        let shared = NodeShared::new(
27639            handle,
27640            "fixture-token".to_owned(),
27641            NodeId::new("node-1").unwrap(),
27642            vec![workspace],
27643            vec![agent("claude")],
27644        );
27645        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27646        let expected_revision = workspace_file_revision(b"old\n");
27647        let request = NodeRequest::WriteWorkspaceFile {
27648            workspace_id: workspace_id.clone(),
27649            path: path.clone(),
27650            expected_revision: expected_revision.clone(),
27651            text: "new\n".to_owned(),
27652        };
27653        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27654            .await
27655            .unwrap_err();
27656        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27657        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27658            .await
27659            .unwrap_err();
27660        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27661        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27662        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27663        let NodeResponse::WorkspaceFileWritten { file } = response else {
27664            panic!("workspace file write returned another response");
27665        };
27666        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27667        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27668        let stale = process_request_inner(
27669            &shared,
27670            77,
27671            ClientRole::Operator,
27672            NodeRequest::WriteWorkspaceFile {
27673                workspace_id,
27674                path,
27675                expected_revision,
27676                text: "lost update\n".to_owned(),
27677            },
27678        )
27679        .await
27680        .unwrap_err();
27681        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27682        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27683        std::fs::remove_dir_all(root).unwrap();
27684    }
27685
27686    #[tokio::test]
27687    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27688        let root = temporary_workspace_root("workspace-entry-create-controller");
27689        std::fs::create_dir_all(root.join("src")).unwrap();
27690        let workspace_id = WorkspaceId::new("primary").unwrap();
27691        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27692        let catalog = active_registry().unwrap();
27693        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27694        let shared = NodeShared::new(
27695            handle,
27696            "fixture-token".to_owned(),
27697            NodeId::new("node-1").unwrap(),
27698            vec![workspace],
27699            vec![agent("claude")],
27700        );
27701        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27702        let request = NodeRequest::CreateWorkspaceFile {
27703            workspace_id: workspace_id.clone(),
27704            path: path.clone(),
27705        };
27706        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27707            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27708        }));
27709        assert!(!request_uses_unnegotiated_capability(
27710            &request,
27711            &baseline_capabilities().unwrap(),
27712        ));
27713        assert!(request_uses_unnegotiated_capability(&request, &[]));
27714        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27715            .await
27716            .unwrap_err();
27717        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27718        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27719            .await
27720            .unwrap_err();
27721        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27722        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27723
27724        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27725            .await
27726            .unwrap();
27727        let NodeResponse::WorkspaceFileCreated { file } = response else {
27728            panic!("workspace file create returned another response");
27729        };
27730        assert_eq!(file.workspace_id, workspace_id);
27731        assert_eq!(file.path, path);
27732        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27733        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27734
27735        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27736            .await
27737            .unwrap_err();
27738        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27739
27740        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27741        let response = process_request_inner(
27742            &shared,
27743            77,
27744            ClientRole::Operator,
27745            NodeRequest::CreateWorkspaceDirectory {
27746                workspace_id: workspace_id.clone(),
27747                path: directory_path.clone(),
27748            },
27749        )
27750        .await
27751        .unwrap();
27752        assert_eq!(
27753            response,
27754            NodeResponse::WorkspaceDirectoryCreated {
27755                workspace_id: workspace_id.clone(),
27756                entry: WorkspaceEntry {
27757                    relative_path: directory_path,
27758                    kind: WorkspaceEntryKind::Directory,
27759                },
27760            },
27761        );
27762        assert!(root.join("src/nested").is_dir());
27763
27764        let missing_parent = process_request_inner(
27765            &shared,
27766            77,
27767            ClientRole::Operator,
27768            NodeRequest::CreateWorkspaceFile {
27769                workspace_id: workspace_id.clone(),
27770                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27771            },
27772        )
27773        .await
27774        .unwrap_err();
27775        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27776        let non_directory_parent = process_request_inner(
27777            &shared,
27778            77,
27779            ClientRole::Operator,
27780            NodeRequest::CreateWorkspaceFile {
27781                workspace_id,
27782                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27783            },
27784        )
27785        .await
27786        .unwrap_err();
27787        assert_eq!(
27788            non_directory_parent.code,
27789            NodeFailureCode::RepositoryParentNotDirectory,
27790        );
27791        std::fs::remove_dir_all(root).unwrap();
27792    }
27793
27794    #[tokio::test]
27795    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27796        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27797        std::fs::create_dir_all(root.join("src")).unwrap();
27798        #[cfg(windows)]
27799        let root = std::fs::canonicalize(root).unwrap();
27800        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27801        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27802        let barrier = Arc::new(std::sync::Barrier::new(2));
27803        let done = Arc::new(AtomicBool::new(false));
27804        let task_commit_state = Arc::clone(&commit_state);
27805        let task_barrier = Arc::clone(&barrier);
27806        let task_done = Arc::clone(&done);
27807        let task_root = root.clone();
27808        let task = tokio::task::spawn_blocking(move || {
27809            task_barrier.wait();
27810            let result = create_workspace_file_on_disk(
27811                &task_root,
27812                &path,
27813                &task_commit_state,
27814            );
27815            task_done.store(true, Ordering::Release);
27816            result
27817        });
27818
27819        let error = settle_workspace_entry_create(
27820            task,
27821            Arc::clone(&commit_state),
27822            Duration::from_millis(10),
27823            "fixture deadline",
27824            "fixture task failed",
27825        )
27826        .await
27827        .unwrap_err();
27828        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27829        assert_eq!(
27830            commit_state.load(Ordering::Acquire),
27831            WORKSPACE_ENTRY_CREATE_CANCELED,
27832        );
27833
27834        barrier.wait();
27835        timeout(Duration::from_secs(1), async {
27836            while !done.load(Ordering::Acquire) {
27837                tokio::task::yield_now().await;
27838            }
27839        })
27840        .await
27841        .expect("canceled blocking create did not settle");
27842        assert!(!root.join("src/late.rs").exists());
27843        std::fs::remove_dir_all(root).unwrap();
27844    }
27845}
27846
27847#[cfg(test)]
27848mod provisional_spawn_tests {
27849    use super::*;
27850    use std::sync::Arc;
27851
27852    fn shared() -> NodeShared {
27853        let catalog = active_registry().unwrap();
27854        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27855        let workspace = WorkspaceConfig::new(
27856            WorkspaceId::new("primary").unwrap(),
27857            std::env::current_dir().unwrap(),
27858        )
27859        .unwrap()
27860        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27861        NodeShared::new(
27862            handle,
27863            "fixture-token".to_owned(),
27864            NodeId::new("node-provisional-test").unwrap(),
27865            vec![workspace],
27866            vec![AgentId::new("claude").unwrap()],
27867        )
27868    }
27869
27870    /// Provisional draft map: sessionize by instance id only; 50 sequential
27871    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27872    #[test]
27873    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27874        let shared = shared();
27875        let ws = WorkspaceId::new("primary").unwrap();
27876        let mut ids = Vec::with_capacity(50);
27877        for i in 1u64..=50 {
27878            let id = AgentInstanceId(i);
27879            ids.push(id);
27880            let address = SessionAddress {
27881                workspace_id: ws.clone(),
27882                session: SessionKey {
27883                    instance_id: id,
27884                    generation: SessionGeneration(1),
27885                },
27886            };
27887            shared.mark_spawn_provisional(address);
27888        }
27889        assert_eq!(
27890            shared
27891                .provisional_spawns
27892                .lock()
27893                .unwrap_or_else(|p| p.into_inner())
27894                .len(),
27895            50
27896        );
27897        for id in ids.iter().step_by(2) {
27898            shared.sessionize_spawn(*id);
27899        }
27900        assert_eq!(
27901            shared
27902                .provisional_spawns
27903                .lock()
27904                .unwrap_or_else(|p| p.into_inner())
27905                .len(),
27906            25
27907        );
27908        assert!(shared.take_due_provisional_spawns().is_empty());
27909        {
27910            let mut map = shared
27911                .provisional_spawns
27912                .lock()
27913                .unwrap_or_else(|p| p.into_inner());
27914            let past = Instant::now() - Duration::from_secs(30);
27915            for (_, (_, at)) in map.iter_mut() {
27916                *at = past;
27917            }
27918        }
27919        let due = shared.take_due_provisional_spawns();
27920        assert_eq!(due.len(), 25);
27921        let due_ids: std::collections::BTreeSet<_> =
27922            due.iter().map(|a| a.session.instance_id.0).collect();
27923        let expected: std::collections::BTreeSet<_> =
27924            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27925        assert_eq!(due_ids, expected);
27926        assert!(shared
27927            .provisional_spawns
27928            .lock()
27929            .unwrap_or_else(|p| p.into_inner())
27930            .is_empty());
27931    }
27932
27933    /// Twenty parallel provisional marks must each get their own instance key;
27934    /// killing/sessionizing one id must not touch the others.
27935    #[test]
27936    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27937        let shared = Arc::new(shared());
27938        let ws = WorkspaceId::new("primary").unwrap();
27939        let mut handles = Vec::new();
27940        for i in 1u64..=20 {
27941            let shared = Arc::clone(&shared);
27942            let ws = ws.clone();
27943            handles.push(std::thread::spawn(move || {
27944                let id = AgentInstanceId(i);
27945                let address = SessionAddress {
27946                    workspace_id: ws,
27947                    session: SessionKey {
27948                        instance_id: id,
27949                        generation: SessionGeneration(1),
27950                    },
27951                };
27952                shared.mark_spawn_provisional(address);
27953                id
27954            }));
27955        }
27956        let mut ids = Vec::new();
27957        for h in handles {
27958            ids.push(h.join().unwrap());
27959        }
27960        ids.sort_by_key(|id| id.0);
27961        assert_eq!(ids.len(), 20);
27962        ids.dedup();
27963        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27964        assert_eq!(
27965            shared
27966                .provisional_spawns
27967                .lock()
27968                .unwrap_or_else(|p| p.into_inner())
27969                .len(),
27970            20
27971        );
27972        shared.sessionize_spawn(AgentInstanceId(7));
27973        let left: Vec<_> = shared
27974            .provisional_spawns
27975            .lock()
27976            .unwrap_or_else(|p| p.into_inner())
27977            .keys()
27978            .map(|id| id.0)
27979            .collect();
27980        assert!(!left.contains(&7));
27981        assert_eq!(left.len(), 19);
27982    }
27983}
27984
27985
27986#[cfg(unix)]
27987extern "C" {
27988    #[link_name = "geteuid"]
27989    fn libc_geteuid() -> u32;
27990}
27991
27992#[cfg(test)]
27993mod call_home_link_tests {
27994    use super::*;
27995
27996    #[test]
27997    fn call_home_backoff_is_capped_and_monotonic() {
27998        let mut last = Duration::ZERO;
27999        for attempt in 1..20 {
28000            let next = call_home_backoff(attempt);
28001            assert!(next >= last);
28002            assert!(next <= Duration::from_secs(8));
28003            last = next;
28004        }
28005        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28006    }
28007
28008    #[tokio::test]
28009    async fn call_home_socket_gets_keepalive() {
28010        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28011        let addr = listener.local_addr().unwrap();
28012        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28013        let client = client.unwrap();
28014        enable_call_home_keepalive(&client).unwrap();
28015        let sock = socket2::SockRef::from(&client);
28016        assert!(sock.keepalive().unwrap());
28017        #[cfg(any(target_os = "linux", target_os = "macos"))]
28018        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28019    }
28020}