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 node_wake;
213
214mod browser_bridge;
215
216use gate4agent_node_core::drive_loop_profile;
217
218#[path = "bundle_delivery.rs"]
219mod bundle_delivery;
220
221#[path = "context_pack_store.rs"]
222mod context_pack_store;
223
224#[cfg(windows)]
225pub use crate::platform::DEFAULT_NODE_ENDPOINT;
226
227const NODE_EVENT_HISTORY_MAX: usize = 4_096;
228const NODE_BROADCAST_CAPACITY: usize = 1_024;
229const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
230/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
231/// terminal frame is a full-screen snapshot; only the latest ever matters),
232/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
233/// two pending sends into one would silently drop content a subscriber can
234/// never recover. This needs headroom for a burst instead.
235const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
236/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
237/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
238/// admission check), and `publish_transient` sends each exactly once as a
239/// single-envelope batch -- so this many slots covers the entire pending
240/// set without ever lagging the one subscriber that matters, the harness's
241/// own connection, the way sharing the durable `event_tx`'s bus could.
242const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
243const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
244const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
245const MAX_PREAUTH_CONNECTIONS: usize = 32;
246const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
247const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
248const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
249const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
250const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
251/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
252/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
253/// not the disconnect reconnect grace (HQ owns that, starting only on a real
254/// client WS close). A provider conversation id is a session; a PTY with no
255/// attached HQ view is still a draft and is reaped here if attach never lands.
256const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
257/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
258/// record retention policy (`NodeServerConfig::session_record_retention`).
259/// A sweep cadence, not a policy value -- the policy itself lives entirely
260/// in the operator-configured age/keep-N flags; this just bounds how stale
261/// the in-memory view of dead `Unavailable` records can get between sweeps
262/// on an otherwise-idle node, mirroring the once-per-startup sweep that
263/// also runs alongside `reconcile_managed_worktrees`.
264const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
265/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
266/// field's own doc for why a spawn waiter needs this at all. Must outlive
267/// the longest a waiter can still be polling for this exact command's
268/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
269/// largest `deadline_ms` any caller may request, and the margin on top
270/// absorbs the waiter's own poll cadence plus clock skew between the drive
271/// loop (writer) and the waiter (reader).
272const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
273/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
274/// backlog of asynchronously-rejected commands nobody ever collects (a
275/// waiter that stops polling, or a burst of always-rejected commands from a
276/// misbehaving caller): once at capacity, the oldest entry is evicted to
277/// make room for the newest, mirroring the eviction rule already used by
278/// `spawn_idempotency`.
279const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
280/// How long `wait_until_removed` waits between two `Remove` re-dispatches
281/// for the same session. The presence poll runs far faster (2ms); this
282/// only paces the sending, so an accepted `Remove` still in flight is not
283/// piled on by a dozen more that land on an already-dropped instance.
284const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
285const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
286const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
287const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
288const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
289const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
290const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
291const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
292const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
293const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
294const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
295const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
296const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
297/// Default total wall-clock budget for `inspect_workspace`'s walk+git
298/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
299const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
300/// The harness relay enforces its own flat response deadline
301/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
302/// 12s) above this node-side budget. The two constants live in different
303/// crates with no shared definition, so clamp any override here comfortably
304/// below that external ceiling — a misconfigured value above it would
305/// reopen the exact deadline race this budget exists to close.
306const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
307/// Default cap on directory entries visited (not just returned) by one
308/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
309const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
310const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
311const GIT_STATUS_MAX_ENTRIES: usize = 128;
312const GIT_COMMIT_MAX_ENTRIES: usize = 12;
313const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
314const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
315const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
316const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
317const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
318const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
319const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
320const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
321const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
322const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
323    "provider-session-identity-allocation-failed";
324const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
325const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
326const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
327const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
328const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
329const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
330const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
331    "durable-state-path-semantics-unsupported";
332
333fn opaque_windows_path(value: String) -> OpaqueHostPath {
334    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
335}
336
337fn agent_progress_from_provider_snapshot(
338    address: SessionAddress,
339    provider: &ProviderSnapshot,
340) -> Option<SessionAgentProgress> {
341    let mut truncated = false;
342    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
343    let mut active_tool_labels = Vec::with_capacity(
344        provider
345            .active_tools
346            .len()
347            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
348    );
349    for tool in &provider.active_tools {
350        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
351            truncated = true;
352            break;
353        }
354        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
355            active_tool_labels.push(label);
356        }
357    }
358    let attention = provider
359        .interactions
360        .iter()
361        .find(|interaction| {
362            matches!(
363                interaction.status,
364                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
365            )
366        })
367        .map(|interaction| AgentProgressAttentionV1 {
368            kind: match interaction.interaction_kind {
369                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
370                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
371            },
372            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
373        });
374    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
375    let usage = (provider.completed_turns > 0
376        || provider.usage.input_tokens > 0
377        || provider.usage.output_tokens > 0
378        || provider.usage.cache_read_tokens > 0
379        || provider.usage.cache_write_tokens > 0
380        || provider.usage.reasoning_tokens > 0)
381        .then_some(AgentProgressUsageV1 {
382            input_tokens: provider.usage.input_tokens,
383            output_tokens: provider.usage.output_tokens,
384            cache_read_tokens: provider.usage.cache_read_tokens,
385            cache_write_tokens: provider.usage.cache_write_tokens,
386            reasoning_tokens: provider.usage.reasoning_tokens,
387        });
388    let progress = AgentProgressV1 {
389        provider_sequence: provider.sequence,
390        activity: provider.activity,
391        completed_turns: provider.completed_turns,
392        usage,
393        current: AgentProgressCurrentV1::from(provider.activity),
394        active_tool_labels,
395        active_tool_count,
396        attention,
397        subagent_count,
398        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
399        gap_count: provider.gap_count,
400        stale: provider.stale,
401        truncated,
402    };
403    let entry = SessionAgentProgress { address, progress };
404    serde_json::to_vec(&entry)
405        .ok()
406        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
407        .map(|_| entry)
408}
409
410fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
411    match u32::try_from(count) {
412        Ok(count) => count,
413        Err(_) => {
414            *truncated = true;
415            u32::MAX
416        }
417    }
418}
419
420fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
421    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
422        return None;
423    }
424    Some(match event {
425        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
426        ProviderEvent::SessionIdentityObserved { .. } => {
427            AgentProgressEventKindV1::SessionIdentityObserved
428        }
429        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
430        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
431        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
432        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
433        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
434        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
435        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
436        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
437        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
438        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
439        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
440        ProviderEvent::InteractionRequested { .. } => {
441            AgentProgressEventKindV1::InteractionRequested
442        }
443        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
444        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
445        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
446        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
447        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
448        ProviderEvent::UnrecognizedNotification { .. } => {
449            AgentProgressEventKindV1::UnrecognizedNotification
450        }
451        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
452        // ACP session/update coverage beyond text/tool/turn streaming
453        // (`plan`, `available_commands_update`, `current_mode_update`,
454        // `session_info_update`, `usage_update`, `config_option_update`,
455        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
456        // wire enum without a `#[serde(other)]` fallback; minting new
457        // variants for it is a deliberate wire-contract decision for
458        // whoever owns this protocol, not a side effect of parsing more of
459        // ACP's own wire -- until that decision is made, these fold into
460        // the same "we saw something, no dedicated kind for it yet" bucket
461        // as `UnrecognizedNotification`.
462        ProviderEvent::UserMessage { .. }
463        | ProviderEvent::Plan { .. }
464        | ProviderEvent::AvailableCommandsUpdated { .. }
465        | ProviderEvent::ModeChanged { .. }
466        | ProviderEvent::SessionInfoUpdated { .. }
467        | ProviderEvent::UsageUpdated { .. }
468        | ProviderEvent::ConfigOptionsUpdated { .. } => {
469            AgentProgressEventKindV1::UnrecognizedNotification
470        }
471    })
472}
473
474/// Maps this harness's own `HostDecisionAuthority` onto the wider
475/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
476/// chunk carries -- the four authorities a `Denied` decision is ever made
477/// under, one-for-one. The remaining `BlockAuthorityV1` variants
478/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
479/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
480/// block the PROVIDER side of the wire reported -- this function never
481/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
482/// them; the provider-side blocks below mint those directly from typed fields.
483/// Whether a provider source is a live session transport -- the only kind
484/// whose events reach the agent stream. History, resume, session-option and
485/// capability-probe sources are not sessions and stream nothing.
486fn provider_family_is_live(family: AdapterFamily) -> bool {
487    match family {
488        AdapterFamily::PtySemantic
489        | AdapterFamily::Hook
490        | AdapterFamily::ManagedHook
491        | AdapterFamily::Pipe
492        | AdapterFamily::OneShot
493        | AdapterFamily::Acp => true,
494        AdapterFamily::History
495        | AdapterFamily::Resume
496        | AdapterFamily::SessionOptions
497        | AdapterFamily::CapabilityProbe => false,
498    }
499}
500
501fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
502    match by {
503        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
504        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
505        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
506        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
507    }
508}
509
510/// The `Blocked::reason` for a `Denied` decision this build's own
511/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
512/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
513/// request_permission` request resolved by `AcpSession::
514/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
515/// neither of which computes free text (an operator answers approve/reject,
516/// never a comment; an unattended deadline asks nobody anything). `reason`
517/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
518/// -- a categorical fallback naming the authority is the
519/// honest minimum, never a placeholder standing in for detail nobody
520/// reported.
521fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
522    match authority {
523        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
524        BlockAuthorityV1::HostPolicy => "denied by host policy",
525        BlockAuthorityV1::HostDeadline => {
526            "denied by host policy after the operator deadline expired"
527        }
528        BlockAuthorityV1::Operator => "declined by the operator",
529        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
530        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
531        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
532        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
533        BlockAuthorityV1::UserRejected => "rejected by the user",
534        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
535        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
536    }
537}
538
539/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
540/// for a `HostRequestObserved` `Denied` decision.
541fn host_request_denied_block(
542    method: &str,
543    by: ProviderHostDecisionAuthority,
544    reason: Option<&str>,
545) -> (String, BlockAuthorityV1, String) {
546    let authority = block_authority(by);
547    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
548    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
549    // rather than truncating -- this cut happens here, at the producer, before
550    // the validator ever sees the text.
551    let (reason_text, _) =
552        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
553    (tool_class_label(method), authority, reason_text)
554}
555
556// ---------------------------------------------------------------------------
557// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
558// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
559// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
560// prompt` RPC error whose vendor code names a quota/limit/rate
561// exhaustion). See
562// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
563// §3 slice K2.
564// ---------------------------------------------------------------------------
565
566/// Classifies ACP's `nonExecutionKind` into the typed authority a block
567/// this build mints under, or `None` when the tool truly did not run for a
568/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
569/// TURN stopped (session closed, the operator cancelled), not that a
570/// policy/classifier/rule refused the call, so those (and any unrecognized
571/// future value) stay a plain `ToolCompleted { success: false }` only, per
572/// this plan's own rule that a tool failure is never inflated into a block
573/// this build cannot actually attribute.
574fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
575    match kind? {
576        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
577        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
578        _ => None,
579    }
580}
581
582/// Splits a provider's own denial sentence into `reason`/`help`, when it
583/// matches the fixed shape Claude Code's classifier/permission-rule denial
584/// uses -- verified live, the owner's sample fixture
585/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
586/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
587/// `authority` is NEVER derived from this text (see this module's own rule
588/// that authority comes only from a typed field) -- only `reason`/`help`
589/// are. Falls back to the full text as `reason` with no `help` when the
590/// shape does not match (a provider whose text does not carry this exact
591/// wording) -- the honest minimum: the tool truly did not run, so SOME
592/// reason is always reported, never fabricated detail beyond what arrived.
593fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
594    let trimmed = text.trim();
595    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
596        return (trimmed.to_owned(), None);
597    };
598    let tail = after_reason.trim_start();
599    let Some((reason, help)) = tail.split_once(". ") else {
600        // "Reason:" present but no ". " to split help off -- the whole
601        // tail is the reason, e.g. "Reason: Blocked by classifier." with
602        // no help text following on some providers/versions.
603        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
604        return (reason.to_owned(), None);
605    };
606    let help = help.trim();
607    if help.is_empty() {
608        (reason.trim().to_owned(), None)
609    } else {
610        (reason.trim().to_owned(), Some(help.to_owned()))
611    }
612}
613
614/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
615/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
616/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
617/// name is never threaded through to its completion). Reads the class off
618/// the guidance tail's own "add a <Class> permission rule" sentence when
619/// present (the shape the owner's sample fixture uses) -- text matching
620/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
621/// this module's own rule). Falls back to the same generic `"Tool"` class
622/// `ToolCompleted`'s own observation already uses when nothing names one.
623fn tool_class_from_denial_help(help: Option<&str>) -> String {
624    help.and_then(|text| text.split("add a ").nth(1))
625        .and_then(|tail| tail.split(' ').next())
626        .filter(|word| !word.is_empty())
627        .map(str::to_owned)
628        .unwrap_or_else(|| "Tool".to_owned())
629}
630
631/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
632/// message) names a quota/rate-limit exhaustion -- Codex's
633/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
634/// contains "limit", the word this build actually keys on; "quota"/"rate"
635/// are kept for whichever OTHER vendor's own code names one of those
636/// instead, never confirmed live, but the honest generalization of the
637/// one confirmed sample.
638fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
639    fn names_quota(s: &str) -> bool {
640        let lower = s.to_ascii_lowercase();
641        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
642    }
643    vendor_code.is_some_and(names_quota) || names_quota(message)
644}
645
646/// The `Blocked` chunk this build mints when a turn stopped via a
647/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
648/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
649/// vendor code (or message) names a quota/limit/rate exhaustion
650/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
651/// `reason_kind` = the vendor code). `None` for every other stop --
652/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
653/// `Other`, or an RPC error whose vendor code/message names none of the
654/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
655/// those, never inflated into a block this build cannot actually
656/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
657/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
658/// honest minimum names the fact that happened, never invented detail; a
659/// later slice that threads the turn's last agent text chunk through can
660/// replace it without changing this function's contract.
661fn provider_session_end_block(
662    stop_reason: &ProviderStopReason,
663) -> Option<(BlockAuthorityV1, String, String)> {
664    match stop_reason {
665        ProviderStopReason::Refusal => Some((
666            BlockAuthorityV1::ProviderRefusal,
667            "refusal".to_owned(),
668            "the provider ended the turn with stopReason: refusal".to_owned(),
669        )),
670        ProviderStopReason::ProviderError { message, vendor_code, .. }
671            if provider_error_is_quota(vendor_code.as_deref(), message) =>
672        {
673            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
674            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
675        }
676        _ => None,
677    }
678}
679
680/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
681/// `agent-stream-events-v1` channel -- see
682/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
683/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
684/// event it derives from, so a client joining the two sees one id and one
685/// ordering:
686///
687/// - `source_sequence` on the chunk is the `source_sequence` the provider
688///   event itself carries.
689/// - `InteractionPrompt::correlation_id` is minted by
690///   `correlation::interaction_correlation` with the
691///   `(instance_id, generation, provider_sequence)` triple -- never the
692///   provider's own `request_id`, whose opacity on the wire is pinned by a
693///   dedicated test.
694///
695/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
696/// (nothing else carries stream content), for a source that is not a live
697/// session transport, and for the `ProviderEvent` variants below with no
698/// declared chunk kind to carry them, or whose declared chunk kind this build
699/// has no source for yet:
700///
701/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
702///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
703///   yet, the same gap the plan names explicitly.
704/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
705///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
706///   as "what the agent is saying", so mapping the operator's words onto
707///   `Text` would misrepresent whose content it is. No dedicated
708///   user-message kind is declared, so this stays unproduced rather than
709///   mislabeled.
710///
711/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
712/// decision returns `None` (nothing for the operator to react to), a `Denied`
713/// decision returns `Some(Blocked { .. })`.
714///
715/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
716/// below (K2): each returns `None` unless
717/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
718/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
719/// RPC error naming a quota).
720fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
721    let ControlEventKind::ProviderEvent {
722        sequence: provider_sequence,
723        source,
724        source_sequence,
725        event: provider_event,
726    } = &event.event
727    else {
728        return None;
729    };
730    if !provider_family_is_live(source.family) {
731        return None;
732    }
733    let kind = match provider_event {
734        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
735            text: text.clone(),
736            is_delta: *is_delta,
737        },
738        ProviderEvent::Thinking { text } => {
739            AgentStreamChunkKindV1::Thinking { text: text.clone() }
740        }
741        ProviderEvent::InteractionRequested {
742            interaction_kind,
743            tool_name,
744            title,
745            prompt,
746            options,
747            ..
748        } => AgentStreamChunkKindV1::InteractionPrompt {
749            correlation_id: interaction_correlation(
750                event.instance_id,
751                event.generation,
752                *provider_sequence,
753            ),
754            interaction_kind: *interaction_kind,
755            tool_name: tool_name.clone(),
756            // `title`/`options` are carried straight through from
757            // `ProviderEvent::InteractionRequested`, as the agent itself
758            // gave them on `session/request_permission`'s `PermissionTool
759            // Call.title`/`options` (see `gate4agent-shell-native`'s
760            // `provider_event`, the only ACP-transport producer). A PTY- or
761            // hook-sourced interaction carries `title: None` and
762            // `options: []` from its own producer because that source has
763            // none to offer, never because this mapping dropped one.
764            title: title.clone(),
765            prompt: prompt.clone(),
766            options: options
767                .iter()
768                .map(|option| AgentStreamInteractionOptionV1 {
769                    option_id: option.option_id.clone(),
770                    name: option.name.clone(),
771                    kind: option.kind.clone(),
772                })
773                .collect(),
774        },
775        ProviderEvent::ConfigOptionsUpdated { options } => {
776            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
777        }
778        // `available` is the mode catalogue `AcpSession::available_modes()`
779        // read back at `session/new` and threaded through by
780        // `gate4agent-shell-native`'s `provider_event` (see that crate's
781        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
782        // agent's own honest "no modes announced", never "not read": ACP
783        // orders `session/new` before any `session/update`, and the
784        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
785        // exist before the same session object's catalogue is already
786        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
787        // own doc comment for the ordering argument in full).
788        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
789            current: Some(mode_id.clone()),
790            available: available
791                .iter()
792                .map(|mode| AgentStreamNamedIdV1 {
793                    id: mode.id.clone(),
794                    name: mode.name.clone(),
795                    description: mode.description.clone(),
796                })
797                .collect(),
798        },
799        // A denied host request is a block -- see this function's own doc
800        // comment for why `HostRequestObserved` is not in the exclusion list
801        // below.
802        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
803            let ProviderHostRequestDecision::Denied { by } = decision else {
804                return None;
805            };
806            let (tool_class, authority, reason_text) =
807                host_request_denied_block(method, *by, reason.as_deref());
808            AgentStreamChunkKindV1::Blocked {
809                correlation_id: None,
810                tool_class,
811                authority,
812                reason_kind: None,
813                reason: reason_text,
814                help: None,
815            }
816        }
817        // Same relationship as `HostRequestObserved` above, driven by ACP's
818        // own `_meta.nonExecutionKind` instead of the host's
819        // `HostRequestDecision`.
820        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
821            let (authority, reason_kind) =
822                provider_tool_block_authority(non_execution_kind.as_deref())?;
823            let (reason, help) = split_provider_denial_text(output);
824            let (reason, _) =
825                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
826            let help = help.map(|help| {
827                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
828            });
829            AgentStreamChunkKindV1::Blocked {
830                correlation_id: Some(tool_correlation(event, source, id)),
831                tool_class: tool_class_from_denial_help(help.as_deref()),
832                authority,
833                reason_kind: Some(reason_kind.to_owned()),
834                reason,
835                help,
836            }
837        }
838        // Same relationship, driven by `stopReason: "refusal"` or a
839        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
840        // instead of a tool-level fact.
841        ProviderEvent::SessionEnded { stop_reason, .. } => {
842            let (authority, reason_kind, reason) =
843                stop_reason.as_ref().and_then(provider_session_end_block)?;
844            let (reason, _) =
845                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
846            AgentStreamChunkKindV1::Blocked {
847                correlation_id: None,
848                tool_class: "Session".to_owned(),
849                authority,
850                reason_kind: Some(reason_kind),
851                reason,
852                help: None,
853            }
854        }
855        ProviderEvent::SessionStarted { .. }
856        | ProviderEvent::SessionIdentityObserved { .. }
857        | ProviderEvent::TurnStarted { .. }
858        | ProviderEvent::WorkingObserved
859        | ProviderEvent::ToolStarted { .. }
860        | ProviderEvent::TurnCompleted { .. }
861        | ProviderEvent::ContextWindowUsage { .. }
862        | ProviderEvent::TurnInterrupted
863        | ProviderEvent::Error { .. }
864        | ProviderEvent::Ready
865        | ProviderEvent::InteractionResolved { .. }
866        | ProviderEvent::SubagentStarted { .. }
867        | ProviderEvent::SubagentStopped { .. }
868        | ProviderEvent::RateLimited { .. }
869        | ProviderEvent::UnrecognizedNotification { .. }
870        | ProviderEvent::UserMessage { .. }
871        | ProviderEvent::Plan { .. }
872        | ProviderEvent::AvailableCommandsUpdated { .. }
873        | ProviderEvent::SessionInfoUpdated { .. }
874        | ProviderEvent::UsageUpdated { .. } => return None,
875    };
876    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
877}
878
879async fn run_native_session_catalog_operation<T, E, F>(
880    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
881    operation: &'static str,
882    execute: F,
883) -> Result<T, NodeFailure>
884where
885    T: Send + 'static,
886    E: std::fmt::Display + Send + 'static,
887    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
888{
889    let task = tokio::task::spawn_blocking(move || {
890        let mut catalog = match catalog.try_lock() {
891            Ok(catalog) => catalog,
892            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
893            Err(std::sync::TryLockError::WouldBlock) => {
894                return Err(failure(
895                    NodeFailureCode::BackendBusy,
896                    "native session catalog operation is already running",
897                ));
898            }
899        };
900        execute(&mut catalog).map_err(|error| {
901            failure(
902                NodeFailureCode::BackendOperationFailed,
903                &format!("{operation} failed: {error}"),
904            )
905        })
906    });
907    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
908        .await
909        .map_err(|_| {
910            failure(
911                NodeFailureCode::BackendBusy,
912                &format!("{operation} exceeded its bounded deadline"),
913            )
914        })?
915        .map_err(|_| {
916            failure(
917                NodeFailureCode::BackendOperationFailed,
918                &format!("{operation} task failed"),
919            )
920        })?
921}
922
923fn windows_path_text(path: &OpaqueHostPath) -> &str {
924    path.as_utf8()
925        .expect("Windows node only creates UTF-8 opaque host paths")
926}
927
928fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
929    path.as_utf8()
930        .map(str::to_owned)
931        .ok_or_else(|| failure(
932            NodeFailureCode::InvalidRequest,
933            "this node requires UTF-8 host paths",
934        ))
935}
936
937fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
938    GitWorktreeSnapshot {
939        path: opaque_windows_path(worktree.path),
940        head: worktree.head,
941        branch: worktree.branch,
942        is_bare: worktree.is_bare,
943        is_main: worktree.is_main,
944        locked: worktree.locked,
945        lock_reason: worktree.lock_reason,
946        prunable: worktree.prunable,
947        prunable_reason: worktree.prunable_reason,
948        workspace_id: worktree.workspace_id.take(),
949    }
950}
951
952#[derive(Clone, Debug, Eq, PartialEq)]
953pub struct WorkspaceConfig {
954    workspace_id: WorkspaceId,
955    canonical_root: String,
956    worktree_service_mode: WorktreeServiceMode,
957    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
958}
959
960impl WorkspaceConfig {
961    pub fn new(
962        workspace_id: WorkspaceId,
963        root: impl AsRef<Path>,
964    ) -> Result<Self, NodeServerError> {
965        let root = root.as_ref();
966        if !root.is_absolute() {
967            return Err(NodeServerError::InvalidWorkspaceRoot {
968                workspace_id,
969                path: root.to_string_lossy().into_owned(),
970                message: "path is not absolute".to_owned(),
971            });
972        }
973        let canonical = std::fs::canonicalize(root).map_err(|error| {
974            NodeServerError::InvalidWorkspaceRoot {
975                workspace_id: workspace_id.clone(),
976                path: root.to_string_lossy().into_owned(),
977                message: error.to_string(),
978            }
979        })?;
980        if !canonical.is_dir() {
981            return Err(NodeServerError::InvalidWorkspaceRoot {
982                workspace_id,
983                path: root.to_string_lossy().into_owned(),
984                message: "path is not a directory".to_owned(),
985            });
986        }
987        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
988            NodeServerError::InvalidWorkspaceRoot {
989                workspace_id: workspace_id.clone(),
990                path: path.to_string_lossy().into_owned(),
991                message: "canonical path is not valid Unicode".to_owned(),
992            }
993        })?;
994        let canonical_root = platform::normalize_canonical_root(canonical_root);
995        validate_workspace_root(&workspace_id, &canonical_root)?;
996        Ok(Self {
997            workspace_id,
998            canonical_root,
999            worktree_service_mode: WorktreeServiceMode::Manual,
1000            managed_worktree_profiles: BTreeMap::new(),
1001        })
1002    }
1003
1004    pub fn workspace_id(&self) -> &WorkspaceId {
1005        &self.workspace_id
1006    }
1007
1008    pub fn canonical_root(&self) -> &str {
1009        &self.canonical_root
1010    }
1011
1012    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1013        self.worktree_service_mode = mode;
1014        self
1015    }
1016
1017    pub fn with_managed_worktree_profile(
1018        mut self,
1019        profile: ManagedWorktreeProfile,
1020    ) -> Result<Self, NodeServerError> {
1021        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1022            NodeServerError::InvalidManagedWorktreeProfile {
1023                workspace_id: self.workspace_id.clone(),
1024                profile_id: profile.profile_id().clone(),
1025                message,
1026            }
1027        })?;
1028        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1029            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1030                workspace_id: self.workspace_id,
1031                profile_id: profile.profile_id().clone(),
1032            });
1033        }
1034        if self.managed_worktree_profiles.len()
1035            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1036        {
1037            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1038                workspace_id: self.workspace_id,
1039                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1040            });
1041        }
1042        self.managed_worktree_profiles
1043            .insert(profile.profile_id().clone(), profile);
1044        Ok(self)
1045    }
1046}
1047
1048#[derive(Clone)]
1049pub struct NodeServerConfig {
1050    pub endpoint: String,
1051    api_listen: Option<std::net::SocketAddr>,
1052    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1053    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1054    bridge_listen: Option<std::net::SocketAddr>,
1055    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1056    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1057    bridge_token: Option<String>,
1058    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1059    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1060    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1061    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1062    /// node purely a listener, which is what it has always been.
1063    call_home: Option<std::net::SocketAddr>,
1064    /// When set, call-home authenticates by signing the relay's challenge with this
1065    /// identity (enrolled node) and falls back to the token handshake against a relay
1066    /// that sends no challenge.
1067    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1068    pub node_id: NodeId,
1069    pub workspaces: Vec<WorkspaceConfig>,
1070    access_token: String,
1071    pub runtime: NativeRuntimeConfig,
1072    session_record_retention: SessionRecordRetentionConfig,
1073    state_path: Option<PathBuf>,
1074    spawn_profiles: SpawnProfileRegistry,
1075    session_environment: Option<NodeSessionEnvironmentConfig>,
1076    history: Option<NativeHistoryConfig>,
1077    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1078    /// Station network allowlist catalog (opaque ids). Empty default.
1079    /// Loaded at startup from `--network-allowlist-catalog` / env
1080    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1081    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1082    #[cfg(feature = "fixture")]
1083    fixture_raw_pty_runtime: bool,
1084}
1085
1086#[derive(Clone)]
1087struct NodeSessionEnvironmentConfig {
1088    root: PathBuf,
1089    resolver: Arc<dyn NodeSecretResolver>,
1090}
1091
1092impl NodeServerConfig {
1093    pub fn new(
1094        endpoint: impl Into<String>,
1095        access_token: impl Into<String>,
1096        node_id: NodeId,
1097        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1098    ) -> Result<Self, NodeServerError> {
1099        let endpoint = endpoint.into();
1100        let access_token = access_token.into();
1101        if !platform::validate_endpoint(&endpoint) {
1102            return Err(NodeServerError::InvalidEndpoint);
1103        }
1104        if access_token.is_empty() || access_token.len() > 4_096 {
1105            return Err(NodeServerError::InvalidAccessToken);
1106        }
1107        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1108        if workspaces.is_empty() {
1109            return Err(NodeServerError::NoWorkspaces);
1110        }
1111        let mut workspace_ids = BTreeMap::new();
1112        let mut workspace_roots = BTreeMap::new();
1113        for workspace in &workspaces {
1114            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1115                && workspace.managed_worktree_profiles.is_empty()
1116            {
1117                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1118                    workspace.workspace_id.clone(),
1119                ));
1120            }
1121            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1122                && !workspace.managed_worktree_profiles.is_empty()
1123            {
1124                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1125                    workspace.workspace_id.clone(),
1126                ));
1127            }
1128            if workspace_ids
1129                .insert(workspace.workspace_id.clone(), ())
1130                .is_some()
1131            {
1132                return Err(NodeServerError::DuplicateWorkspaceId(
1133                    workspace.workspace_id.clone(),
1134                ));
1135            }
1136            let root_key = platform::root_identity(&workspace.canonical_root);
1137            if let Some(existing) = workspace_roots.insert(
1138                root_key,
1139                workspace.workspace_id.clone(),
1140            ) {
1141                return Err(NodeServerError::DuplicateWorkspaceRoot {
1142                    first: existing,
1143                    second: workspace.workspace_id.clone(),
1144                    root: workspace.canonical_root.clone(),
1145                });
1146            }
1147        }
1148        Ok(Self {
1149            endpoint,
1150            api_listen: None,
1151            bridge_listen: None,
1152            bridge_token: None,
1153            bridge_underlay: None,
1154            call_home: None,
1155            call_home_identity: None,
1156            node_id,
1157            workspaces,
1158            access_token,
1159            // PTY output is polled by the runtime's effect worker; the upstream
1160            // default (20ms) added ~10-20ms to every keystroke echo.
1161            runtime: NativeRuntimeConfig {
1162                worker_poll_interval_ms: 4,
1163                ..NativeRuntimeConfig::default()
1164            },
1165            session_record_retention: SessionRecordRetentionConfig::default(),
1166            state_path: None,
1167            spawn_profiles: SpawnProfileRegistry::default(),
1168            session_environment: None,
1169            history: None,
1170            harness_mcp_helper: None,
1171            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1172            #[cfg(feature = "fixture")]
1173            fixture_raw_pty_runtime: false,
1174        })
1175    }
1176
1177    pub fn with_api_listen(
1178        mut self,
1179        api_listen: std::net::SocketAddr,
1180    ) -> Result<Self, NodeServerError> {
1181        if !api_listen.ip().is_loopback() {
1182            return Err(NodeServerError::InvalidApiListen(api_listen));
1183        }
1184        self.api_listen = Some(api_listen);
1185        Ok(self)
1186    }
1187
1188    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1189    /// as `with_api_listen`). Does not enable by default — libraries and the
1190    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1191    pub fn with_bridge_listen(
1192        mut self,
1193        bridge_listen: std::net::SocketAddr,
1194    ) -> Result<Self, NodeServerError> {
1195        if !bridge_listen.ip().is_loopback() {
1196            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1197        }
1198        self.bridge_listen = Some(bridge_listen);
1199        Ok(self)
1200    }
1201
1202    /// Optional bridge shared secret, distinct from the C2/node access token.
1203    /// Never log the value. Empty / oversize refused.
1204    pub fn with_bridge_token(
1205        mut self,
1206        bridge_token: impl Into<String>,
1207    ) -> Result<Self, NodeServerError> {
1208        let bridge_token = bridge_token.into();
1209        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1210            return Err(NodeServerError::InvalidBridgeToken);
1211        }
1212        self.bridge_token = Some(bridge_token);
1213        Ok(self)
1214    }
1215
1216    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1217    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1218    /// already configured. Transport key is 32 raw bytes (never logged).
1219    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1220    pub fn with_bridge_underlay(
1221        mut self,
1222        bind: std::net::SocketAddr,
1223        transport_key: [u8; 32],
1224        underlay_token: impl Into<String>,
1225    ) -> Result<Self, NodeServerError> {
1226        if self.bridge_listen.is_none() {
1227            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1228        }
1229        if self.bridge_token.is_none() {
1230            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1231        }
1232        let underlay_token = underlay_token.into();
1233        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1234            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1235        }
1236        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1237        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1238            bind,
1239            transport_key,
1240            underlay_token,
1241        });
1242        Ok(self)
1243    }
1244
1245    /// Also dial `relay` and serve this wire over the connection, in
1246    /// addition to accepting on the local endpoint.
1247    ///
1248    /// Who opens the socket and who is the protocol's server are separate
1249    /// questions, and this is the node's half of separating them. The node
1250    /// stays the server either way -- it answers `NodeRequest`s and emits
1251    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1252    /// that changes is that a node with no address anyone can reach makes
1253    /// the connection itself, and names itself once it arrives (see
1254    /// `gate4agent_node_wire::write_call_home_announce`).
1255    ///
1256    /// Loopback only, and that is not an oversight to be flagged and
1257    /// worked around. This wire carries terminal contents, keystrokes and
1258    /// file bytes as plaintext JSON; its mutual challenge-response
1259    /// authenticates both ends and encrypts nothing. Every listener in
1260    /// this stack is loopback-guarded for that reason, and a dialler must
1261    /// hold the same line -- a node calling a public relay would put the
1262    /// whole control plane on the wire in the clear. Reaching a relay on
1263    /// another host is a transport question, and it stays unanswered here
1264    /// on purpose: this change is about direction, not distance.
1265    pub fn with_call_home(
1266        mut self,
1267        relay: std::net::SocketAddr,
1268    ) -> Result<Self, NodeServerError> {
1269        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1270        // and port 0 refused — plaintext wire must not dial every interface.
1271        if relay.port() == 0 || relay.ip().is_unspecified() {
1272            return Err(NodeServerError::InvalidCallHome(relay));
1273        }
1274        self.call_home = Some(relay);
1275        Ok(self)
1276    }
1277
1278    /// Authenticate call-home with a node-held identity instead of a standing token.
1279    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1280        self.call_home_identity = Some(identity);
1281        self
1282    }
1283
1284    pub fn with_state_path(
1285        mut self,
1286        state_path: impl Into<PathBuf>,
1287    ) -> Result<Self, NodeServerError> {
1288        let state_path = state_path.into();
1289        if !state_path.is_absolute() || state_path.file_name().is_none() {
1290            return Err(NodeServerError::InvalidStatePath(
1291                state_path.to_string_lossy().into_owned(),
1292            ));
1293        }
1294        self.state_path = Some(state_path);
1295        Ok(self)
1296    }
1297
1298    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1299        self.spawn_profiles = spawn_profiles;
1300        self
1301    }
1302
1303    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1304    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1305    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1306    /// when this is never called -- a node never deletes a durable record
1307    /// until an operator opts in with a real value.
1308    pub fn with_session_record_retention(
1309        mut self,
1310        retention: SessionRecordRetentionConfig,
1311    ) -> Self {
1312        self.session_record_retention = retention;
1313        self
1314    }
1315
1316    pub fn with_session_environment_materialization(
1317        mut self,
1318        root: impl Into<PathBuf>,
1319        resolver: Arc<dyn NodeSecretResolver>,
1320    ) -> Result<Self, NodeServerError> {
1321        let root = root.into();
1322        if !root.is_absolute() {
1323            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1324        }
1325        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1326        Ok(self)
1327    }
1328
1329    /// Enables bounded provider-history discovery and loading from explicit
1330    /// host-owned roots. Ambient vendor homes are never inferred here.
1331    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1332        self.history = Some(history);
1333        self
1334    }
1335
1336    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1337    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1338    /// Both platforms review the helper the same way (absolute regular file +
1339    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1340    /// gate after Unix listener/peer-cred support landed.
1341    pub fn with_harness_mcp_helper(
1342        mut self,
1343        helper_program: impl Into<PathBuf>,
1344    ) -> Result<Self, NodeServerError> {
1345        self.harness_mcp_helper = Some(
1346            ReviewedHarnessMcpProgram::review(helper_program.into())
1347                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1348        );
1349        Ok(self)
1350    }
1351
1352    /// Install an in-memory station network allowlist catalog (opaque ids +
1353    /// optional node-local permits / provider-native mapping). Empty remains
1354    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1355    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1356    pub fn with_network_allowlist_catalog(
1357        mut self,
1358        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1359    ) -> Result<Self, NodeServerError> {
1360        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1361            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1362                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1363            });
1364        }
1365        self.network_allowlist_catalog = catalog;
1366        Ok(self)
1367    }
1368
1369    /// Load station network allowlist catalog from an absolute regular file
1370    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1371    /// that want env fallback use `resolve_network_allowlist_catalog`.
1372    pub fn with_network_allowlist_catalog_file(
1373        self,
1374        path: impl AsRef<std::path::Path>,
1375    ) -> Result<Self, NodeServerError> {
1376        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1377            .map_err(NodeServerError::from)?;
1378        self.with_network_allowlist_catalog(catalog)
1379    }
1380}
1381
1382pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1383    platform::default_state_path(node_id.as_str())
1384        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1385}
1386
1387pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1388    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1389}
1390
1391fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1392    let parent = state_path.parent()?;
1393    let mut name = state_path.file_name()?.to_os_string();
1394    name.push(".delivery-store");
1395    Some(parent.join(name))
1396}
1397
1398fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1399    let parent = state_path.parent()?;
1400    let mut name = state_path.file_name()?.to_os_string();
1401    name.push(".context-pack-store");
1402    Some(parent.join(name))
1403}
1404
1405pub struct NodeServer {
1406    config: NodeServerConfig,
1407    runtime: NativeRuntime,
1408    shared: Arc<NodeShared>,
1409    events: EventSubscription,
1410    state_path_lock: Option<session_registry::StatePathLock>,
1411}
1412
1413#[cfg(feature = "fixture")]
1414#[derive(Clone, Default)]
1415pub struct SpawnManagedWorktreeV2FailureProbe {
1416    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1417}
1418
1419#[cfg(feature = "fixture")]
1420impl SpawnManagedWorktreeV2FailureProbe {
1421    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1422        *self
1423            .latest_code
1424            .lock()
1425            .unwrap_or_else(|poisoned| poisoned.into_inner())
1426    }
1427
1428    fn record(&self, code: Option<NodeFailureCode>) {
1429        *self
1430            .latest_code
1431            .lock()
1432            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1433    }
1434}
1435
1436#[cfg(all(feature = "fixture", windows))]
1437struct IsolatedCodexHomeFixtureEnvironment;
1438
1439#[cfg(all(feature = "fixture", windows))]
1440impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1441    for IsolatedCodexHomeFixtureEnvironment
1442{
1443    fn resolve_child_environment(
1444        &self,
1445    ) -> Result<
1446        Vec<gate4agent_catalog::EnvMutation>,
1447        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1448    > {
1449        Ok(vec![gate4agent_catalog::EnvMutation {
1450            key: OsString::from("USERPROFILE"),
1451            value: None,
1452        }])
1453    }
1454}
1455
1456impl NodeServer {
1457    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1458    /// must present.
1459    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1460        self.shared.m4a_accounts.as_ref()?;
1461        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1462    }
1463
1464    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1465        let catalog = active_registry()?;
1466        Self::new_with_registry(config, catalog)
1467    }
1468
1469    #[cfg(feature = "fixture")]
1470    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1471        let mut spec = gate4agent_testkit::interactive_agent_spec();
1472        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1473        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1474        Self::new_fixture_with_spec(config, spec)
1475    }
1476
1477    #[cfg(feature = "fixture")]
1478    pub fn new_clean_exit_fixture(
1479        config: NodeServerConfig,
1480        fixture_root: PathBuf,
1481        started_marker: PathBuf,
1482        release_signal: PathBuf,
1483    ) -> Result<Self, NodeServerError> {
1484        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1485            &fixture_root,
1486            &started_marker,
1487            &release_signal,
1488        )
1489        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490        spec.id = AgentId::new("claude")
1491            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1492        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1493        Self::new_fixture_with_spec(config, spec)
1494    }
1495
1496    /// A `claude` source whose session establishes a verified provider
1497    /// identity via an early `SessionStart` Hook post (so it is eligible for
1498    /// auto-export-at-exit and its native history is independently
1499    /// loadable), waits for `release_signal`, then exits cleanly — paired
1500    /// on the same Node with a plain `codex` target that never validates
1501    /// anything about what it receives. Built for A2 durable ContextPack
1502    /// E2E coverage: the test itself inspects materialized files and
1503    /// managed-session-record fields directly, rather than having the
1504    /// target script self-validate.
1505    #[cfg(all(feature = "fixture", windows))]
1506    pub fn new_durable_context_pack_clean_exit_fixture(
1507        config: NodeServerConfig,
1508        fixture_root: PathBuf,
1509        started_marker: PathBuf,
1510        release_signal: PathBuf,
1511        session_id: &str,
1512    ) -> Result<Self, NodeServerError> {
1513        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1514            &fixture_root,
1515            &started_marker,
1516            &release_signal,
1517            session_id,
1518        )
1519        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1520        let source_id = AgentId::new("claude")
1521            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1522        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1523            NodeServerError::Registry(
1524                "durable context pack clean-exit fixture source adapter is unavailable"
1525                    .to_owned(),
1526            )
1527        })?;
1528        source_spec.capabilities.adapters.history =
1529            source_provider.capabilities.adapters.history.clone();
1530        source_spec.id = source_id;
1531        source_spec.display_name =
1532            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1533        // Both specs launch via `powershell.exe`; the registry's ambiguity
1534        // check keys on `detection.command` (+ aliases), not `launch.program`
1535        // -- give each a distinct detection identity, same precedent as
1536        // `context_pack_fixture_catalog`'s per-provider
1537        // `gate4agent-{provider}-context-fixture.cmd` commands.
1538        source_spec.detection.command =
1539            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1540        source_spec.detection.aliases.clear();
1541
1542        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1543        target_spec.id = AgentId::new("codex")
1544            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1545        target_spec.display_name =
1546            "Controlled Codex durable context-pack target fixture".to_owned();
1547        target_spec.detection.command =
1548            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1549        target_spec.detection.aliases.clear();
1550
1551        let catalog = AgentRegistry::new([source_spec, target_spec])
1552            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let mut server = Self::new_with_registry(config, catalog)?;
1554        Arc::get_mut(&mut server.shared)
1555            .expect("fixture Node shared state must remain uniquely owned before run")
1556            .fixture_semantic_hook_policy = true;
1557        Ok(server)
1558    }
1559
1560    #[cfg(feature = "fixture")]
1561    pub fn new_monitoring_hook_fixture(
1562        config: NodeServerConfig,
1563    ) -> Result<Self, NodeServerError> {
1564        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1565        spec.id = AgentId::new("claude")
1566            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567        let mut server = Self::new_fixture_with_spec(config, spec)?;
1568        Arc::get_mut(&mut server.shared)
1569            .expect("fixture Node shared state must remain uniquely owned before run")
1570            .fixture_semantic_hook_policy = true;
1571        Ok(server)
1572    }
1573
1574    #[cfg(all(feature = "fixture", windows))]
1575    pub fn new_provider_bundle_argv_fixture(
1576        config: NodeServerConfig,
1577        agent_id: AgentId,
1578        proof_path: PathBuf,
1579    ) -> Result<Self, NodeServerError> {
1580        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1581        Self::new_fixture_with_spec(config, spec)
1582    }
1583
1584    #[cfg(all(feature = "fixture", windows))]
1585    pub fn new_provider_bundle_argv_hold_fixture(
1586        config: NodeServerConfig,
1587        agent_id: AgentId,
1588        proof_path: PathBuf,
1589        release_signal: PathBuf,
1590    ) -> Result<Self, NodeServerError> {
1591        let spec = Self::provider_bundle_argv_fixture_spec(
1592            agent_id,
1593            proof_path,
1594            Some(release_signal),
1595        )?;
1596        Self::new_fixture_with_spec(config, spec)
1597    }
1598
1599    #[cfg(all(feature = "fixture", windows))]
1600    fn provider_bundle_argv_fixture_spec(
1601        agent_id: AgentId,
1602        proof_path: PathBuf,
1603        release_signal: Option<PathBuf>,
1604    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1605        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1606            return Err(NodeServerError::Registry(
1607                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1608            ));
1609        }
1610        let mut spec = gate4agent_testkit::interactive_agent_spec();
1611        spec.id = agent_id.clone();
1612        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1613        let provider = builtin_registry()
1614            .get(&agent_id)
1615            .ok_or_else(|| {
1616                NodeServerError::Registry(
1617                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1618                )
1619            })?;
1620        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1621        if !proof_path.is_absolute()
1622            || !proof_path
1623                .parent()
1624                .is_some_and(|parent| parent.is_dir())
1625        {
1626            return Err(NodeServerError::Registry(
1627                "provider bundle argv proof path requires an existing absolute parent"
1628                    .to_owned(),
1629            ));
1630        }
1631        if let Some(release_signal) = release_signal.as_ref() {
1632            if !release_signal.is_absolute()
1633                || !release_signal
1634                    .parent()
1635                    .is_some_and(|parent| parent.is_dir())
1636            {
1637                return Err(NodeServerError::Registry(
1638                    "provider bundle argv release path requires an existing absolute parent"
1639                        .to_owned(),
1640                ));
1641            }
1642            if release_signal.exists() {
1643                return Err(NodeServerError::Registry(
1644                    "provider bundle argv release path must not exist".to_owned(),
1645                ));
1646            }
1647            let proof_parent = std::fs::canonicalize(
1648                proof_path
1649                    .parent()
1650                    .expect("validated provider bundle proof parent"),
1651            )
1652            .map_err(|_| {
1653                NodeServerError::Registry(
1654                    "provider bundle argv proof parent is unavailable".to_owned(),
1655                )
1656            })?;
1657            let release_parent = std::fs::canonicalize(
1658                release_signal
1659                    .parent()
1660                    .expect("validated provider bundle release parent"),
1661            )
1662            .map_err(|_| {
1663                NodeServerError::Registry(
1664                    "provider bundle argv release parent is unavailable".to_owned(),
1665                )
1666            })?;
1667            if proof_parent != release_parent {
1668                return Err(NodeServerError::Registry(
1669                    "provider bundle argv release path must share the proof parent".to_owned(),
1670                ));
1671            }
1672        }
1673        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1674            NodeServerError::Registry(
1675                "provider bundle argv proof path is not valid Unicode".to_owned(),
1676            )
1677        })?;
1678        let release_signal = release_signal.map(|release_signal| {
1679            release_signal.into_os_string().into_string().map_err(|_| {
1680                NodeServerError::Registry(
1681                    "provider bundle argv release path is not valid Unicode".to_owned(),
1682                )
1683            })
1684        }).transpose()?;
1685        let script = spec
1686            .launch
1687            .fixed_args
1688            .pop()
1689            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1690        let bundle_validation = if agent_id.as_str() == "codex" {
1691            "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') }"
1692        } else if agent_id.as_str() == "claude" {
1693            "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 }"
1694        } else {
1695            "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 }"
1696        };
1697        if let Some(release_signal) = release_signal {
1698            spec.launch.fixed_args.push(format!(
1699                "& {{ 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} }}",
1700            ));
1701            spec.launch.fixed_args.push(proof_path);
1702            spec.launch.fixed_args.push(release_signal);
1703        } else {
1704            spec.launch.fixed_args.push(format!(
1705                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1706            ));
1707            spec.launch.fixed_args.push(proof_path);
1708        }
1709        Ok(spec)
1710    }
1711
1712    #[cfg(all(feature = "fixture", windows))]
1713    pub fn new_context_pack_fixture(
1714        config: NodeServerConfig,
1715        proof_path: PathBuf,
1716    ) -> Result<Self, NodeServerError> {
1717        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1718        Self::new_with_registry(config, catalog)
1719    }
1720
1721    #[cfg(all(feature = "fixture", windows))]
1722    pub fn new_monitoring_context_pack_fixture(
1723        config: NodeServerConfig,
1724        proof_path: PathBuf,
1725    ) -> Result<Self, NodeServerError> {
1726        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1727        let mut server = Self::new_with_registry(config, catalog)?;
1728        Arc::get_mut(&mut server.shared)
1729            .expect("fixture Node shared state must remain uniquely owned before run")
1730            .fixture_semantic_hook_policy = true;
1731        Ok(server)
1732    }
1733
1734    #[cfg(all(feature = "fixture", windows))]
1735    pub fn install_isolated_codex_home_environment_fixture(
1736        &mut self,
1737        profile_id: SpawnEnvironmentProfileId,
1738        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1739    ) -> Result<(), NodeServerError> {
1740        let provider = AgentId::new("codex")
1741            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1742        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1743            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1744                "{}-pty",
1745                profile_id.as_str(),
1746            ))
1747            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1748            provider.clone(),
1749            TransportKind::Pty,
1750            vec![OsString::from("USERPROFILE")],
1751            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1752        )
1753        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1754        let materialization = NodeSessionMaterializationProfile::new(
1755            Vec::new(),
1756            vec![crate::session_environment::NodeSessionPathBinding::new(
1757                "CODEX_HOME",
1758                crate::session_environment::NodeSessionPathClass::ProviderHome,
1759            )
1760            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1761            Vec::new(),
1762        )
1763        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1764        self.install_environment_profile(
1765            NodeEnvironmentProfile::new_with_materialization(
1766                profile_id,
1767                profile_revision,
1768                provider,
1769                [native_profile],
1770                Some(materialization),
1771            )
1772            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1773        )
1774    }
1775
1776    #[cfg(all(feature = "fixture", windows))]
1777    pub fn new_context_only_proof_fixture(
1778        config: NodeServerConfig,
1779        target_provider: AgentId,
1780        proof_path: PathBuf,
1781    ) -> Result<Self, NodeServerError> {
1782        if target_provider.as_str() != "kimi" {
1783            return Err(NodeServerError::Registry(
1784                "context-only proof fixture requires Kimi".to_owned(),
1785            ));
1786        }
1787        let catalog = Self::context_pack_fixture_catalog(
1788            proof_path,
1789            Some(&target_provider),
1790            false,
1791        )?;
1792        Self::new_with_registry(config, catalog)
1793    }
1794
1795    #[cfg(all(feature = "fixture", windows))]
1796    fn context_pack_fixture_catalog(
1797        proof_path: PathBuf,
1798        context_only_target: Option<&AgentId>,
1799        monitoring_claude_source: bool,
1800    ) -> Result<AgentRegistry, NodeServerError> {
1801        if !proof_path.is_absolute()
1802            || !proof_path
1803                .parent()
1804                .is_some_and(|parent| parent.is_dir())
1805        {
1806            return Err(NodeServerError::Registry(
1807                "context pack proof path requires an existing absolute parent".to_owned(),
1808            ));
1809        }
1810        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1811            NodeServerError::Registry(
1812                "context pack proof path is not valid Unicode".to_owned(),
1813            )
1814        })?;
1815        let providers = ["claude", "codex", "grok", "kimi"];
1816        let builtins = builtin_registry();
1817        let mut specs = Vec::with_capacity(providers.len());
1818        for provider_id in providers {
1819            let agent_id = AgentId::new(provider_id)
1820                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1821            let provider = builtins.get(&agent_id).ok_or_else(|| {
1822                NodeServerError::Registry(
1823                    "context pack fixture provider adapter is unavailable".to_owned(),
1824                )
1825            })?;
1826            let monitored_provider =
1827                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1828            let mut spec = if monitored_provider {
1829                gate4agent_testkit::monitoring_hook_agent_spec()
1830            } else {
1831                gate4agent_testkit::interactive_agent_spec()
1832            };
1833            spec.id = agent_id.clone();
1834            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1835            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1836            spec.detection.aliases.clear();
1837            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1838            if provider_id == "codex" {
1839                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1840                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1841                })?;
1842                let validation = r#"
1843$bundleArgs = @($bundleArgs)
1844if ($bundleArgs.Count -ne 0) { exit 91 }
1845$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1846$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1847$cwd = (Get-Location).ProviderPath
1848if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1849$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1850if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1851$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1852if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1853$sha = [Security.Cryptography.SHA256]::Create()
1854try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1855if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1856if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1857$contextPath = Join-Path $contextRoot 'context-pack.json'
1858if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1859$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1860if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1861$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1862$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1863$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1864$separator = [IO.Path]::DirectorySeparatorChar
1865if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1866if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1867$rawContext = [IO.File]::ReadAllText($contextPath)
1868if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1869try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1870$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1871$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1872foreach ($property in @($document.PSObject.Properties.Name)) {
1873    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1874}
1875foreach ($property in $requiredTopLevel) {
1876    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1877}
1878if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1879if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1880$messages = @($document.retained_messages)
1881if ($messages.Count -eq 0) { exit 107 }
1882foreach ($message in $messages) {
1883    $messageProperties = @($message.PSObject.Properties.Name)
1884    foreach ($property in $messageProperties) {
1885        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1886    }
1887    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1888}
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891[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))
1892$rawProof = [IO.File]::ReadAllText($proofPath)
1893if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1894[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1895"#;
1896                spec.launch.fixed_args.push(format!(
1897                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1898                ));
1899                spec.launch.fixed_args.push(proof_path.clone());
1900            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1901                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1902                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1903                })?;
1904                let validation = r#"
1905$bundleArgs = @($bundleArgs)
1906if ($bundleArgs.Count -ne 0) { exit 91 }
1907$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1908$cwd = (Get-Location).ProviderPath
1909if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1910$contextPath = Join-Path $contextRoot 'context-pack.json'
1911if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1912$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1913if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1914$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1915$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1916$separator = [IO.Path]::DirectorySeparatorChar
1917if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1918try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1919if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1920if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1921$messages = @($document.retained_messages)
1922if ($messages.Count -eq 0) { exit 99 }
1923$hasUser = $false
1924$hasAssistant = $false
1925foreach ($message in $messages) {
1926    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1927    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1928    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1929    else { exit 101 }
1930}
1931if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1932$sha = [Security.Cryptography.SHA256]::Create()
1933try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1934[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1935[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1936"#;
1937                spec.launch.fixed_args.push(format!(
1938                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1939                ));
1940                spec.launch.fixed_args.push(proof_path.clone());
1941            }
1942            specs.push(spec);
1943        }
1944        let catalog = AgentRegistry::new(specs)
1945            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1946        Ok(catalog)
1947    }
1948
1949    #[cfg(feature = "fixture")]
1950    pub fn new_exact_launcher_fixture(
1951        config: NodeServerConfig,
1952        agent_id: AgentId,
1953        launcher: String,
1954    ) -> Result<Self, NodeServerError> {
1955        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1956            return Err(NodeServerError::Registry(
1957                "exact launcher fixture requires a supported provider".to_owned(),
1958            ));
1959        }
1960        if launcher.contains('\0') {
1961            return Err(NodeServerError::Registry(
1962                "exact launcher fixture path contains NUL".to_owned(),
1963            ));
1964        }
1965        let launcher_path = Path::new(&launcher);
1966        if !launcher_path.is_absolute() {
1967            return Err(NodeServerError::Registry(
1968                "exact launcher fixture path must be absolute".to_owned(),
1969            ));
1970        }
1971        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1972            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1973        })?;
1974        if !metadata.is_file() {
1975            return Err(NodeServerError::Registry(
1976                "exact launcher fixture path must be a regular file".to_owned(),
1977            ));
1978        }
1979        let mut spec = builtin_registry()
1980            .get(&agent_id)
1981            .ok_or_else(|| {
1982                NodeServerError::Registry(
1983                    "exact launcher fixture provider is unavailable".to_owned(),
1984                )
1985            })?
1986            .clone();
1987        spec.launch.program = launcher;
1988        Self::new_fixture_with_spec(config, spec)
1989    }
1990
1991    /// Shared H3B validation and spec construction for
1992    /// `new_harness_mcp_proxy_fixture` and
1993    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1994    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1995    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1996    /// the named builtin catalog entry -- detection command and expected
1997    /// process matcher rebased on the launcher's own basename, exactly the
1998    /// same shape `new_harness_mcp_proxy_fixture` always built.
1999    #[cfg(feature = "fixture")]
2000    fn h3b_provider_spec_with_program_and_args(
2001        agent_id: AgentId,
2002        provider_program: PathBuf,
2003        fixed_args: Vec<OsString>,
2004    ) -> Result<AgentSpec, NodeServerError> {
2005        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2006            return Err(NodeServerError::Registry(
2007                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2008            ));
2009        }
2010        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2011            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2012        })?;
2013        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2014            return Err(NodeServerError::Registry(
2015                "H3B fixture provider must be an exact regular file".to_owned(),
2016            ));
2017        }
2018        let program = provider_program.into_os_string().into_string().map_err(|_| {
2019            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2020        })?;
2021        let fixed_args = fixed_args.into_iter().map(|argument| {
2022            argument.into_string().map_err(|_| NodeServerError::Registry(
2023                "H3B fixture argument is not Unicode".to_owned(),
2024            ))
2025        }).collect::<Result<Vec<_>, _>>()?;
2026        let argument_bytes = fixed_args.iter()
2027            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2028        if program.contains('\0')
2029            || fixed_args.iter().any(|argument| argument.contains('\0'))
2030            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2031        {
2032            return Err(NodeServerError::Registry(
2033                "H3B fixture provider or arguments are invalid".to_owned(),
2034            ));
2035        }
2036        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2037            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2038        })?.clone();
2039        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2040            .ok_or_else(|| NodeServerError::Registry(
2041                "H3B fixture provider basename is unavailable".to_owned(),
2042            ))?.to_owned();
2043        spec.detection.command = process_name.clone();
2044        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2045            name: process_name,
2046        }];
2047        spec.launch.program = program;
2048        spec.launch.fixed_args = fixed_args;
2049        Ok(spec)
2050    }
2051
2052    #[cfg(feature = "fixture")]
2053    pub fn new_harness_mcp_proxy_fixture(
2054        config: NodeServerConfig,
2055        provider_program: PathBuf,
2056        fixed_args: Vec<OsString>,
2057        harness_mcp_program: PathBuf,
2058    ) -> Result<Self, NodeServerError> {
2059        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2060        let agent_id = AgentId::new("codex")
2061            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2062        let mut spec =
2063            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2064        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2065        config.fixture_raw_pty_runtime = true;
2066        Self::new_fixture_with_spec(config, spec)
2067    }
2068
2069    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2070    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2071    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2072    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2073    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2074    /// fixture_raw_pty_runtime = true`, which tears out the live
2075    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2076    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2077    /// hardcode `acp_transport = false` for every provider, refusing
2078    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2079    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2080    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2081    /// is built from the (cloned, launcher-replaced) catalog spec, and
2082    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2083    /// unmodified catalog fact: every builtin provider this accepts
2084    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2085    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2086    /// invented here -- this constructor only removes the fixture-only
2087    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2088    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2089    /// for every ACP session, fixture or not) is what actually grants the
2090    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2091    /// this constructor never calls it directly, and does not need to.
2092    #[cfg(feature = "fixture")]
2093    pub fn new_harness_mcp_acp_launcher_fixture(
2094        config: NodeServerConfig,
2095        helper_program: PathBuf,
2096        provider: AgentId,
2097        program: PathBuf,
2098        args: Vec<OsString>,
2099    ) -> Result<Self, NodeServerError> {
2100        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2101            return Err(NodeServerError::Registry(
2102                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2103            ));
2104        }
2105        let config = config.with_harness_mcp_helper(helper_program)?;
2106        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2107        let launch = spec.launch.clone();
2108        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2109            NodeServerError::Registry(
2110                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2111                    .to_owned(),
2112            )
2113        })?;
2114        // Routes the ACP spawn itself through the exact replaced launcher
2115        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2116        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2117        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2118        // without this the ACP transport ignores `spec.launch` entirely and
2119        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2120        // codex-acp@1.10.0`), which this fixture must never reach.
2121        acp.launch_override = Some(launch);
2122        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2123        Self::new_fixture_with_spec(config, spec)
2124    }
2125
2126    #[cfg(feature = "fixture")]
2127    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2128        let mut spec = gate4agent_testkit::interactive_agent_spec();
2129        let claude_id = AgentId::new("claude")
2130            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2131        let claude = builtin_registry()
2132            .get(&claude_id)
2133            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2134        spec.id = claude_id;
2135        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2136        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2137            name: "claude".to_owned(),
2138        }];
2139        spec.capabilities.transports.pty_adapter = claude
2140            .capabilities
2141            .transports
2142            .pty_adapter
2143            .clone();
2144        spec.capabilities.adapters.resume = claude
2145            .capabilities
2146            .adapters
2147            .resume
2148            .clone();
2149        let script = spec
2150            .launch
2151            .fixed_args
2152            .pop()
2153            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2154        spec.launch.fixed_args.push(format!(
2155            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2156        ));
2157        Self::new_fixture_with_spec(config, spec)
2158    }
2159
2160    #[cfg(feature = "fixture")]
2161    pub fn new_cmd_cwd_fixture(
2162        config: NodeServerConfig,
2163        command: String,
2164    ) -> Result<Self, NodeServerError> {
2165        let mut spec = gate4agent_testkit::interactive_agent_spec();
2166        spec.id = AgentId::new("claude")
2167            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2168        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2169        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2170        spec.launch.program = command;
2171        spec.launch.fixed_args.clear();
2172        Self::new_fixture_with_spec(config, spec)
2173    }
2174
2175    #[cfg(feature = "fixture")]
2176    fn new_fixture_with_spec(
2177        config: NodeServerConfig,
2178        spec: gate4agent_types::AgentSpec,
2179    ) -> Result<Self, NodeServerError> {
2180        let catalog = AgentRegistry::new([spec])
2181            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2182        Self::new_with_registry(config, catalog)
2183    }
2184
2185    fn new_with_registry(
2186        config: NodeServerConfig,
2187        catalog: AgentRegistry,
2188    ) -> Result<Self, NodeServerError> {
2189        for profile in config.spawn_profiles.iter() {
2190            if catalog.get(&profile.provider).is_none() {
2191                return Err(NodeServerError::Registry(
2192                    "spawn profile references an unavailable provider".to_owned(),
2193                ));
2194            }
2195        }
2196        let input_settle_timeout_ms = catalog
2197            .iter()
2198            .map(|spec| spec.readiness.timeout_ms)
2199            .max()
2200            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2201            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2202            .max(MUTATION_SETTLE_TIMEOUT_MS);
2203        let (provider_contracts, provider_adapter_contracts) =
2204            provider_contract_manifest(&catalog)?;
2205        let compatibility_support = node_compatibility_support_for_manifest(
2206            &provider_contracts,
2207            &provider_adapter_contracts,
2208        )?;
2209        validate_node_negotiated_handshake_capacity(&compatibility_support)
2210            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2211        let enabled_providers = provider_contracts
2212            .iter()
2213            .map(|contract| contract.provider.clone())
2214            .collect();
2215        #[cfg(feature = "fixture")]
2216        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2217        #[cfg(not(feature = "fixture"))]
2218        let fixture_raw_pty_runtime = false;
2219        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2220            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2221                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2222            })).expect("fixture provider catalog remains bounded");
2223            (None, statuses)
2224        } else {
2225            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2226            let statuses = monitor.collect();
2227            (Some(monitor), statuses)
2228        };
2229        let state_path_lock =
2230            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2231                .map_err(|error| {
2232                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2233                })?;
2234        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2235            Some(state_path) => {
2236                let delivery_root = delivery_store_root_for_state_path(state_path)
2237                    .ok_or_else(|| {
2238                        NodeServerError::DeliveryStore(
2239                            "delivery store root could not be derived from state path".to_owned(),
2240                        )
2241                    })?;
2242                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2243                    let detail = match &error {
2244                        DeliveryStoreError::Storage(io_error) => {
2245                            format!("{error}: {io_error}")
2246                        }
2247                        _ => error.to_string(),
2248                    };
2249                    NodeServerError::DeliveryStore(detail)
2250                })?;
2251                (Some(store), bundles)
2252            }
2253            None => (None, Vec::new()),
2254        };
2255        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2256            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2257        })?;
2258        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2259            Some(state_path) => {
2260                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2261                    .ok_or(NodeServerError::ContextPackStore)?;
2262                let (store, packs) = ContextPackStore::open(context_pack_root)
2263                    .map_err(|_| NodeServerError::ContextPackStore)?;
2264                (Some(store), packs)
2265            }
2266            None => (None, Vec::new()),
2267        };
2268        let mut durable_context_catalog = ContextPackCatalog::default();
2269        for pack in durable_context_packs {
2270            durable_context_catalog
2271                .insert(pack)
2272                .map_err(|_| NodeServerError::ContextPackStore)?;
2273        }
2274        let session_environment_materializer = config
2275            .session_environment
2276            .as_ref()
2277            .map(|environment| {
2278                SessionEnvironmentMaterializer::new(
2279                    environment.root.clone(),
2280                    Arc::clone(&environment.resolver),
2281                )
2282                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2283            })
2284            .transpose()?;
2285        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2286        // (the provider version probe above already used the original catalog).
2287        #[cfg(unix)]
2288        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2289        let native_session_catalog = config.history.clone().map(|history| {
2290            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2291        });
2292        let (handle, runtime) = match config.history.clone() {
2293            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2294            None => NativeRuntime::new(catalog, config.runtime),
2295        };
2296        let native_launch_profile_control = runtime.native_launch_profile_control();
2297        let harness_mcp_registry = config
2298            .harness_mcp_helper
2299            .clone()
2300            .map(HarnessMcpProxyRegistry::new);
2301        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2302        let incarnation_id = random_incarnation_id()
2303            .map_err(NodeServerError::IncarnationIdentity)?;
2304        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2305            .map_err(durable_state_load_error)?;
2306        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2307        let managed_worktree_tombstones =
2308            std::mem::take(&mut loaded.managed_worktree_tombstones);
2309        let materializations = std::mem::take(&mut loaded.materializations);
2310        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2311        let (workspaces, records, persistence_warning) =
2312            merge_durable_state(&config.workspaces, loaded)?;
2313        let mut shared = NodeShared::new_with_incarnation(
2314            handle,
2315            config.access_token.clone(),
2316            config.node_id.clone(),
2317            incarnation_id,
2318            workspaces,
2319            enabled_providers,
2320            provider_runtime_statuses,
2321            provider_runtime_monitor,
2322            provider_contracts,
2323            provider_adapter_contracts,
2324            config.spawn_profiles.clone(),
2325            Some(native_launch_profile_control),
2326            native_session_catalog,
2327            session_environment_materializer,
2328            config.state_path.clone(),
2329            records,
2330            managed_worktrees,
2331            managed_worktree_tombstones,
2332            materializations,
2333            persistence_warning,
2334            input_settle_timeout_ms,
2335        );
2336        shared.harness_mcp_registry = harness_mcp_registry.clone();
2337        shared.session_record_retention = config.session_record_retention;
2338        shared.network_allowlist_catalog =
2339            RwLock::new(config.network_allowlist_catalog.clone());
2340        shared.bundle_catalog = RwLock::new(delivered_catalog);
2341        shared.delivery_store = Mutex::new(delivery_store);
2342        shared.context_catalog = RwLock::new(durable_context_catalog);
2343        shared.context_pack_store = Mutex::new(context_pack_store);
2344        shared.managed_spawn_replays = Mutex::new(
2345            managed_spawn_replays
2346                .into_iter()
2347                .map(|record| (record.idempotency_key.clone(), record))
2348                .collect(),
2349        );
2350        let shared = Arc::new(shared);
2351        if let Some(registry) = harness_mcp_registry {
2352            let weak = Arc::downgrade(&shared);
2353            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2354                Some(shared) => shared.publish_transient(event),
2355                None => false,
2356            }));
2357        }
2358        if shared.persistence_error().is_none() {
2359            shared.persist_state().map_err(|error| {
2360                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2361            })?;
2362        }
2363        Ok(Self {
2364            config,
2365            runtime,
2366            shared,
2367            events,
2368            state_path_lock,
2369        })
2370    }
2371
2372    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2373        NodeShutdownHandle {
2374            shared: Arc::clone(&self.shared),
2375        }
2376    }
2377
2378    #[cfg(feature = "fixture")]
2379    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2380        &self,
2381    ) -> SpawnManagedWorktreeV2FailureProbe {
2382        self.shared
2383            .fixture_spawn_managed_worktree_v2_failure_probe
2384            .clone()
2385    }
2386
2387    /// Installs one immutable host-local environment profile before the server runs.
2388    ///
2389    /// Native resolvers remain inside the runtime; the shared node registry keeps
2390    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2391    pub fn install_environment_profile(
2392        &mut self,
2393        profile: NodeEnvironmentProfile,
2394    ) -> Result<(), NodeServerError> {
2395        if !self.shared.enabled_providers.contains(profile.provider()) {
2396            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2397                profile.provider().clone(),
2398            ));
2399        }
2400        let (binding, native_profiles, materialization) = profile.into_parts();
2401        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2402            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2403        }
2404        let mut profiles = self
2405            .shared
2406            .environment_profiles
2407            .write()
2408            .unwrap_or_else(|poisoned| poisoned.into_inner());
2409        if profiles.contains_key(&binding.id) {
2410            return Err(NodeServerError::DuplicateEnvironmentProfile(
2411                binding.id.clone(),
2412            ));
2413        }
2414        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2415            return Err(NodeServerError::EnvironmentProfileCapacity {
2416                max: MAX_NODE_ENVIRONMENT_PROFILES,
2417            });
2418        }
2419        for native_id in binding.native_profile_ids() {
2420            if profiles.values().any(|current| {
2421                current.native_profile_ids().any(|current_id| current_id == native_id)
2422            }) {
2423                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2424                    native_id.to_string(),
2425                ));
2426            }
2427        }
2428
2429        let mut installed = Vec::with_capacity(native_profiles.len());
2430        for native_profile in native_profiles {
2431            let native_id = native_profile.id().clone();
2432            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2433                for installed_id in installed.into_iter().rev() {
2434                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2435                }
2436                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2437            }
2438            installed.push(native_id);
2439        }
2440        if let Some(materialization) = materialization {
2441            self.shared
2442                .environment_materialization_profiles
2443                .write()
2444                .unwrap_or_else(|poisoned| poisoned.into_inner())
2445                .insert(binding.id.clone(), materialization);
2446        }
2447        profiles.insert(binding.id.clone(), binding);
2448        Ok(())
2449    }
2450
2451    /// Installs one immutable host-local bundle before the server runs.
2452    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2453        if self.shared.session_environment_materializer.is_none() {
2454            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2455        }
2456        let mut catalog = self
2457            .shared
2458            .bundle_catalog
2459            .write()
2460            .unwrap_or_else(|poisoned| poisoned.into_inner());
2461        catalog
2462            .insert_idempotent(bundle)
2463            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2464        Ok(())
2465    }
2466
2467    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2468        let shutdown = self.shutdown_handle();
2469        let run = self.run();
2470        tokio::pin!(run);
2471        tokio::select! {
2472            result = &mut run => result,
2473            signal = wait_for_ctrl_signal() => {
2474                signal?;
2475                let shutdown_result = shutdown.request_shutdown().await;
2476                let run_result = run.await;
2477                shutdown_result.and(run_result)
2478            }
2479        }
2480    }
2481
2482    pub async fn run(self) -> Result<(), NodeServerError> {
2483        let NodeServer {
2484            config,
2485            mut runtime,
2486            shared,
2487            events,
2488            state_path_lock: _state_path_lock,
2489        } = self;
2490        shared.reconcile_materializations();
2491        shared.reconcile_managed_worktrees().await;
2492        shared.retire_unavailable_session_records().await;
2493        shared.reconcile_context_pack_exports().await;
2494        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2495        // observes sessions through ACP where a provider has it; there is no
2496        // hook ingress.
2497        let endpoint = config.endpoint.clone();
2498        let call_home = config.call_home;
2499        let call_home_identity = config.call_home_identity.clone();
2500        let api_listen = config.api_listen;
2501        let bridge_listen = config.bridge_listen;
2502        let bridge_underlay = config.bridge_underlay;
2503        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2504            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2505        let accept_shared = Arc::clone(&shared);
2506        let api_shared = Arc::clone(&shared);
2507        let bridge_shared = Arc::clone(&shared);
2508        let shutdown_shared = Arc::clone(&shared);
2509        let shutdown_timeout = Duration::from_millis(
2510            config.runtime.provider_shutdown_timeout_ms.max(1),
2511        );
2512        let result = {
2513            let runtime_loop = drive_runtime_until_shutdown(
2514                &mut runtime,
2515                events,
2516                Arc::clone(&shared),
2517                shutdown_timeout,
2518            );
2519            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2520            let api_loop = http_api::run(api_listen, api_shared);
2521            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2522            tokio::pin!(runtime_loop);
2523            tokio::pin!(accept_loop);
2524            tokio::pin!(api_loop);
2525            tokio::pin!(bridge_loop);
2526            tokio::select! {
2527                runtime_result = &mut runtime_loop => {
2528                    let shutdown_result = shutdown_shared
2529                        .begin_shutdown()
2530                        .await
2531                        .map_err(NodeServerError::ShutdownDispatch);
2532                    let (accept_result, api_result, bridge_result) =
2533                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2534                    match runtime_result {
2535                        Err(error) => Err(error),
2536                        Ok(()) => shutdown_result
2537                            .and(accept_result)
2538                            .and(api_result.map_err(NodeServerError::HttpApi))
2539                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2540                    }
2541                }
2542                accept_result = &mut accept_loop => {
2543                    let shutdown_result = shutdown_shared
2544                        .begin_shutdown()
2545                        .await
2546                        .map_err(NodeServerError::ShutdownDispatch);
2547                    let (runtime_result, api_result, bridge_result) =
2548                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2549                    match accept_result {
2550                        Err(error) => Err(error),
2551                        Ok(()) => shutdown_result
2552                            .and(runtime_result)
2553                            .and(api_result.map_err(NodeServerError::HttpApi))
2554                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2555                    }
2556                }
2557                api_result = &mut api_loop => {
2558                    let shutdown_result = shutdown_shared
2559                        .begin_shutdown()
2560                        .await
2561                        .map_err(NodeServerError::ShutdownDispatch);
2562                    let (runtime_result, accept_result, bridge_result) =
2563                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2564                    match api_result {
2565                        Err(error) => Err(NodeServerError::HttpApi(error)),
2566                        Ok(()) => shutdown_result
2567                            .and(runtime_result)
2568                            .and(accept_result)
2569                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2570                    }
2571                }
2572                bridge_result = &mut bridge_loop => {
2573                    let shutdown_result = shutdown_shared
2574                        .begin_shutdown()
2575                        .await
2576                        .map_err(NodeServerError::ShutdownDispatch);
2577                    let (runtime_result, accept_result, api_result) =
2578                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2579                    match bridge_result {
2580                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2581                        Ok(()) => shutdown_result
2582                            .and(runtime_result)
2583                            .and(accept_result)
2584                            .and(api_result.map_err(NodeServerError::HttpApi)),
2585                    }
2586                }
2587            }
2588        };
2589        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2590        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2591        // NodeShutdownHandle also clones that Arc, so without an explicit release
2592        // here a supervisor that keeps the shutdown handle cannot reopen the same
2593        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2594        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2595        if let Ok(mut guard) = shared.delivery_store.lock() {
2596            *guard = None;
2597        }
2598        if let Ok(mut guard) = shared.context_pack_store.lock() {
2599            *guard = None;
2600        }
2601        result
2602    }
2603}
2604
2605async fn drive_runtime_until_shutdown(
2606    runtime: &mut NativeRuntime,
2607    events: EventSubscription,
2608    shared: Arc<NodeShared>,
2609    shutdown_timeout: Duration,
2610) -> Result<(), NodeServerError> {
2611    // Published once, never per iteration. The worker loops inside
2612    // `NativeRuntime` fold into this profile continuously; turning it into
2613    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2614    let _ = shared
2615        .shell_efficiency_profile
2616        .set(runtime.shell_efficiency_profile());
2617    let mut shutdown_started = None;
2618    let mut last_retention_sweep = Instant::now();
2619    let mut last_provisional_reap = Instant::now();
2620    loop {
2621        // Every phase below is timed unconditionally -- see
2622        // `drive_loop_profile`'s own doc comment for why an always-on,
2623        // allocation-free ring is cheap enough to never need a feature
2624        // flag. This is the only place these numbers can be taken: the
2625        // phases are calls made directly from this loop, not something a
2626        // callee could time on our behalf.
2627        let runtime_tick_start = Instant::now();
2628        let tick_result = runtime.tick().await;
2629        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2630
2631        let event_drain_start = Instant::now();
2632        // Every command the kernel refused, named. `NativeRuntimeTick`
2633        // has carried these outcomes all along and nothing read them, so a
2634        // rejected command -- a hook event denied by runtime policy, a
2635        // stale generation, an unsupported capability -- vanished at the
2636        // last step of the loop that produced it. A refusal the operator
2637        // cannot see is indistinguishable from an event that never arrived,
2638        // and telling those two apart is most of debugging this stack.
2639        for outcome in &tick_result.command_outcomes {
2640            if let Err(error) = &outcome.result {
2641                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2642                tracing::warn!(
2643                    command_id = outcome.command_id.0,
2644                    cause = %error,
2645                    "control command rejected"
2646                );
2647                shared.record_rejected_command(
2648                    outcome.command_id,
2649                    error.to_string(),
2650                    error.is_unsupported_transport(),
2651                );
2652            }
2653        }
2654        let mut events_drained: u64 = 0;
2655        while let Ok(event) = events.try_recv() {
2656            events_drained += 1;
2657            let clean_exit = matches!(
2658                event.event,
2659                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2660            );
2661            let instance_id = event.instance_id;
2662            let generation = event.generation;
2663            // Resolve and spawn before publish_control: publish_control's own
2664            // managed-record bookkeeping (reconcile_managed_record) downgrades
2665            // this exact record from Live to Dormant and clears
2666            // active_session for every Exited event, inside the same
2667            // synchronous call. Capturing (record_id, session) here — before
2668            // that runs — is the only way to observe them still Live and
2669            // still populated; the export itself must still run on its own
2670            // detached task (never awaited inline on this loop) because it
2671            // needs NativeRuntime::tick() to keep running to ever settle, and
2672            // tick() is only ever driven by this same loop. See
2673            // reconcile_context_pack_export_for_record's doc comment.
2674            if clean_exit {
2675                if let Some((record_id, session)) =
2676                    shared.managed_record_export_target_for_instance(instance_id, generation)
2677                {
2678                    let export_shared = Arc::clone(&shared);
2679                    tokio::spawn(async move {
2680                        export_shared
2681                            .reconcile_context_pack_export_for_record(&record_id, session)
2682                            .await;
2683                    });
2684                }
2685            }
2686            shared.publish_control(event);
2687        }
2688        let event_drain_elapsed = event_drain_start.elapsed();
2689
2690        let publish_terminal_frames_start = Instant::now();
2691        shared.publish_terminal_frames();
2692        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2693
2694        // Physical PTY count and the runtime's own per-phase tick timings
2695        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2696        // only `NodeShared`) cannot reach directly -- this loop is the one
2697        // place holding `&mut NativeRuntime`, so it is the only place that
2698        // can copy either out.
2699        shared
2700            .native_session_gauge
2701            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2702        *shared
2703            .runtime_tick_profile
2704            .lock()
2705            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2706
2707        let remainder_start = Instant::now();
2708        if shared.shutdown.load(Ordering::Acquire) {
2709            let started = *shutdown_started.get_or_insert_with(Instant::now);
2710            let snapshot = shared.handle.snapshot();
2711            let sessions_settled = snapshot.sessions.iter().all(|session| {
2712                !matches!(
2713                    session.status,
2714                    gate4agent_types::SessionStatus::Starting
2715                        | gate4agent_types::SessionStatus::Running
2716                        | gate4agent_types::SessionStatus::Stopping
2717                )
2718            });
2719            if runtime.active_native_sessions() == 0 && sessions_settled {
2720                return Ok(());
2721            }
2722            if started.elapsed() >= shutdown_timeout {
2723                return Err(NodeServerError::ShutdownTimedOut {
2724                    active_native_sessions: runtime.active_native_sessions(),
2725                });
2726            }
2727        }
2728        let remainder_elapsed = remainder_start.elapsed();
2729
2730        {
2731            let mut profile = shared
2732                .drive_loop_profile
2733                .lock()
2734                .unwrap_or_else(|poisoned| poisoned.into_inner());
2735            profile.record_runtime_tick(runtime_tick_elapsed);
2736            profile.record_event_drain(event_drain_elapsed);
2737            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2738            profile.record_remainder(remainder_elapsed);
2739            profile.note_iteration(Instant::now());
2740        }
2741
2742        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2743        // applied zero observations (`tick_result.observations_applied`,
2744        // which already folds in any terminal frames collected -- see
2745        // `NativeEffectDispatcher::drain_observations`) AND the
2746        // control-event subscription drained above forwarded zero events.
2747        // Command ingress and dispatched effects are deliberately NOT part
2748        // of this definition: an idle node still runs `step_control_plane`
2749        // and every provider supervisor's `tick` regardless, every 10ms --
2750        // so "nothing" here means nothing OBSERVABLE left this iteration,
2751        // matching what a reader would assume from `drive_loop_iterations`'s
2752        // own field name, not that the tick itself was free of work.
2753        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2754        if tick_result.observations_applied == 0 && events_drained == 0 {
2755            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2756        }
2757
2758        // A sweep cadence, not a policy value -- see
2759        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2760        // policy itself (whether this does anything at all) lives entirely
2761        // in `NodeShared::session_record_retention`.
2762        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2763            shared.retire_unavailable_session_records().await;
2764            last_retention_sweep = Instant::now();
2765        }
2766        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2767        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2768            for address in shared.take_due_provisional_spawns() {
2769                shared.reap_provisional_spawn(address).await;
2770            }
2771            last_provisional_reap = Instant::now();
2772        }
2773
2774        // Fast-poll for 300ms after PTY input so its echo is read and published
2775        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2776        let hot = shared
2777            .pty_input_marks
2778            .lock()
2779            .unwrap_or_else(|poisoned| poisoned.into_inner())
2780            .values()
2781            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2782        if hot {
2783            sleep(Duration::from_millis(1)).await;
2784        } else {
2785            tokio::select! {
2786                _ = sleep(Duration::from_millis(10)) => {}
2787                _ = shared.pty_input_wake.notified() => {}
2788            }
2789        }
2790    }
2791}
2792
2793#[derive(Clone)]
2794pub struct NodeShutdownHandle {
2795    shared: Arc<NodeShared>,
2796}
2797
2798impl NodeShutdownHandle {
2799    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2800        self.shared
2801            .begin_shutdown()
2802            .await
2803            .map_err(NodeServerError::ShutdownDispatch)
2804    }
2805}
2806
2807async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2808    #[cfg(windows)]
2809    {
2810    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2811    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2812    return tokio::select! {
2813        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2814        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2815    };
2816    }
2817    #[cfg(unix)]
2818    {
2819        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2820        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2821        tokio::select! {
2822            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2823            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2824        }
2825    }
2826}
2827
2828/// The providers this node actually runs.
2829///
2830/// `builtin_registry()` is a reference catalog of everything the project has
2831/// ever described; the fleet is this list, and it is deliberately short. A
2832/// provider only belongs here if it can be exercised for real -- there is a
2833/// subscription behind it and a failure in it can be reproduced and
2834/// debugged. Carrying one that cannot is carrying code nobody can verify,
2835/// which is worse than not supporting it: it looks supported.
2836///
2837/// A provider dropped from the fleet by this rule is removed outright, not
2838/// carried dark: no catalog entry, no adapter, no dead code claiming support
2839/// for something nobody can verify runs.
2840/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2841fn shell_host_spec() -> AgentSpec {
2842    AgentSpec {
2843        id: AgentId::new("shell").expect("shell agent id"),
2844        revision: "hatchery:shell-pty-1".to_owned(),
2845        display_name: "Host shell".to_owned(),
2846        detection: DetectionSpec {
2847            command: "bash".to_owned(),
2848            aliases: vec!["sh".to_owned(), "shell".to_owned()],
2849            required_commands: Vec::new(),
2850            unsupported_platforms: Vec::new(),
2851        },
2852        launch: LaunchSpec {
2853            // Absolute path so admission does not depend on PATH.
2854            program: "/bin/bash".to_owned(),
2855            fixed_args: vec!["-i".to_owned()],
2856        },
2857        expected_processes: vec![ProcessMatcher::Exact {
2858            name: "bash".to_owned(),
2859        }],
2860        prompt: PromptSpec {
2861            initial: InitialPromptMode::None,
2862            native_draft: None,
2863        },
2864        readiness: AgentReadinessSpec::default(),
2865        capabilities: AgentCapabilities {
2866            agent_commands: None,
2867            transports: AgentTransportCapabilities {
2868                pty: true,
2869                pty_adapter: None,
2870                pipe: None,
2871                acp: None,
2872            },
2873            adapters: AgentAdapterCapabilities::default(),
2874        },
2875        verification: SpecVerification::Gate4AgentVerified,
2876    }
2877}
2878
2879fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2880    let mut specs = vec![shell_host_spec()];
2881    specs.extend(
2882        builtin_registry()
2883            .iter()
2884            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2885            .cloned(),
2886    );
2887    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2888}
2889
2890fn provider_contract_manifest(
2891    catalog: &AgentRegistry,
2892) -> Result<
2893    (
2894        Vec<ProviderContractSupport>,
2895        Vec<ProviderAdapterContractSupport>,
2896    ),
2897    NodeServerError,
2898> {
2899    let mut provider_contracts = Vec::new();
2900    let mut provider_adapter_contracts = Vec::new();
2901    for spec in catalog.iter() {
2902        let provider = spec.id.clone();
2903        let revision = ProviderContractRevision::new(spec.revision.clone())
2904            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2905        provider_contracts.push(ProviderContractSupport {
2906            provider: provider.clone(),
2907            revision,
2908        });
2909        for (family, binding) in declared_adapter_bindings(spec)? {
2910            let revision = AdapterContractRevision::new(binding.revision.clone())
2911                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2912            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2913                provider: provider.clone(),
2914                family,
2915                adapter_id: binding.id.clone(),
2916                revision,
2917            });
2918        }
2919    }
2920    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2921        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2922    Ok((provider_contracts, provider_adapter_contracts))
2923}
2924
2925fn declared_adapter_bindings(
2926    spec: &AgentSpec,
2927) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2928    let mut bindings = Vec::new();
2929    push_declared_binding(
2930        &mut bindings,
2931        AdapterFamily::PtySemantic,
2932        spec.capabilities.transports.pty_adapter.as_ref(),
2933    )?;
2934    push_declared_binding(
2935        &mut bindings,
2936        AdapterFamily::Pipe,
2937        spec.capabilities.adapters.pty_sidecar.as_ref(),
2938    )?;
2939    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2940    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2941        gate4agent_types::PipeProtocol::SemanticNdjson
2942        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2943        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2944    });
2945    push_declared_binding(
2946        &mut bindings,
2947        AdapterFamily::Pipe,
2948        pipe_transport
2949            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2950            .map(|transport| &transport.adapter),
2951    )?;
2952    push_declared_binding(
2953        &mut bindings,
2954        AdapterFamily::Acp,
2955        spec.capabilities
2956            .transports
2957            .acp
2958            .as_ref()
2959            .map(|transport| &transport.adapter),
2960    )?;
2961    push_declared_binding(
2962        &mut bindings,
2963        AdapterFamily::Hook,
2964        spec.capabilities.adapters.hook.as_ref(),
2965    )?;
2966    push_declared_binding(
2967        &mut bindings,
2968        AdapterFamily::ManagedHook,
2969        spec.capabilities.adapters.managed_hook.as_ref(),
2970    )?;
2971    push_declared_binding(
2972        &mut bindings,
2973        AdapterFamily::OneShot,
2974        pipe_transport
2975            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2976            .map(|transport| &transport.adapter),
2977    )?;
2978    push_declared_binding(
2979        &mut bindings,
2980        AdapterFamily::OneShot,
2981        spec.capabilities.adapters.one_shot.as_ref(),
2982    )?;
2983    push_declared_binding(
2984        &mut bindings,
2985        AdapterFamily::History,
2986        spec.capabilities.adapters.history.as_ref(),
2987    )?;
2988    push_declared_binding(
2989        &mut bindings,
2990        AdapterFamily::Resume,
2991        spec.capabilities.adapters.resume.as_ref(),
2992    )?;
2993    push_declared_binding(
2994        &mut bindings,
2995        AdapterFamily::SessionOptions,
2996        spec.capabilities.adapters.session_options.as_ref(),
2997    )?;
2998    push_declared_binding(
2999        &mut bindings,
3000        AdapterFamily::CapabilityProbe,
3001        spec.capabilities.adapters.capability_probe.as_ref(),
3002    )?;
3003    Ok(bindings)
3004}
3005
3006fn push_declared_binding<'a>(
3007    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3008    family: AdapterFamily,
3009    binding: Option<&'a AdapterBinding>,
3010) -> Result<(), NodeServerError> {
3011    let Some(binding) = binding else {
3012        return Ok(());
3013    };
3014    if let Some((_, existing)) = bindings
3015        .iter()
3016        .find(|(existing_family, _)| *existing_family == family)
3017    {
3018        if *existing == binding {
3019            return Ok(());
3020        }
3021        return Err(NodeServerError::ProviderContractManifest(format!(
3022            "provider declares conflicting {family:?} adapter bindings"
3023        )));
3024    }
3025    bindings.push((family, binding));
3026    Ok(())
3027}
3028
3029fn merge_durable_state(
3030    configured: &[WorkspaceConfig],
3031    mut loaded: LoadedNodeState,
3032) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3033    let mut workspaces = configured
3034        .iter()
3035        .cloned()
3036        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3037        .collect::<BTreeMap<_, _>>();
3038    let mut roots = workspaces
3039        .values()
3040        .map(|workspace| {
3041            (
3042                platform::root_identity(&workspace.canonical_root),
3043                workspace.workspace_id.clone(),
3044            )
3045        })
3046        .collect::<BTreeMap<_, _>>();
3047    let mut warning = loaded
3048        .warning
3049        .take()
3050        .map(|message| session_registry::sanitized_persistence_summary(&message));
3051
3052    for (workspace_id, canonical_root) in loaded.workspaces {
3053        if let Some(existing) = workspaces.get(&workspace_id) {
3054            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3055                return Err(NodeServerError::DurableState(io::Error::new(
3056                    io::ErrorKind::InvalidData,
3057                    DURABLE_STATE_CONFLICT_ERROR,
3058                )));
3059            }
3060            continue;
3061        }
3062        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3063            return Err(NodeServerError::DurableState(io::Error::new(
3064                io::ErrorKind::InvalidData,
3065                DURABLE_STATE_CONFLICT_ERROR,
3066            )));
3067        }
3068        if !Path::new(&canonical_root).is_dir() {
3069            warning.get_or_insert_with(|| {
3070                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3071            });
3072            continue;
3073        }
3074        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3075            NodeServerError::DurableState(io::Error::new(
3076                io::ErrorKind::InvalidData,
3077                DURABLE_STATE_CONFLICT_ERROR,
3078            ))
3079        })?;
3080        roots.insert(
3081            platform::root_identity(&workspace.canonical_root),
3082            workspace.workspace_id.clone(),
3083        );
3084        workspaces.insert(workspace_id, workspace);
3085    }
3086
3087    for record in &mut loaded.records {
3088        match workspaces.get(&record.workspace_id) {
3089            Some(workspace)
3090                if platform::roots_equal(
3091                    &workspace.canonical_root,
3092                    windows_path_text(&record.canonical_root),
3093                ) => {}
3094            Some(_) => {
3095                return Err(NodeServerError::DurableState(io::Error::new(
3096                    io::ErrorKind::InvalidData,
3097                    DURABLE_STATE_CONFLICT_ERROR,
3098                )));
3099            }
3100            None => {
3101                record.state = ManagedSessionState::Unavailable;
3102                record.active_session = None;
3103                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3104            }
3105        }
3106    }
3107    Ok((workspaces.into_values().collect(), loaded.records, warning))
3108}
3109
3110/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3111/// own doc for the mechanism this exists to close.
3112struct RejectedCommandReason {
3113    message: String,
3114    /// True when the kernel refused this command specifically because the
3115    /// agent does not declare the requested transport (`KernelCommandError::
3116    /// UnsupportedTransport`) -- lets the reader choose the named
3117    /// `NodeFailureCode::UnsupportedTransport` over the generic
3118    /// `BackendOperationFailed` bucket, so this one well-known rejection
3119    /// keeps its identity all the way to the operator wire.
3120    unsupported_transport: bool,
3121    expires_at: Instant,
3122}
3123
3124struct NodeShared {
3125    handle: Gate4AgentHandle,
3126    access_token: String,
3127    node_id: NodeId,
3128    incarnation_id: NodeIncarnationId,
3129    started_at_unix_ms: u64,
3130    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3131    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3132    managed_worktree_profiles:
3133        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3134    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3135    enabled_providers: Vec<AgentId>,
3136    provider_runtime_statuses: ProviderRuntimeStatuses,
3137    provider_runtime_status_updates:
3138        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3139    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3140    provider_contracts: Vec<ProviderContractSupport>,
3141    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3142    spawn_profiles: SpawnProfileRegistry,
3143    environment_profiles:
3144        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3145    environment_materialization_profiles:
3146        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3147    /// Station **network** allowlist catalog (opaque policy ids only).
3148    /// Empty by default — any `network_allowlist` on spawn must be registered
3149    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3150    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3151    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3152    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3153    /// Never holds cookies / OAuth / proxy credentials.
3154    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3155    /// Node-local exclusive dig2browser station profile leases (feature
3156    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3157    /// cleared on session end. Never ImportSession / cookies on C2.
3158    #[cfg(feature = "dig2-station-probe")]
3159    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3160    bundle_catalog: RwLock<BundleCatalog>,
3161    delivery_store: Mutex<Option<DeliveryStore>>,
3162    context_pack_store: Mutex<Option<ContextPackStore>>,
3163    context_catalog: RwLock<ContextPackCatalog>,
3164    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3165    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3166    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3167    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3168    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3169    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3170    /// nick -> the session that holds that m4a account (for letter wakes).
3171    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3172    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3173    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3174    managed_spawn_replays:
3175        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3176    controller: Mutex<Option<ControllerLease>>,
3177    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3178    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3179    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3180    history: Mutex<NodeEventHistory>,
3181    event_tx: broadcast::Sender<NodeEventEnvelope>,
3182    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3183    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3184    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3185    /// exactly rather than riding the durable `event_tx` history (see
3186    /// `publish`'s own assertion): a subscriber who never negotiated
3187    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3188    /// these, the same guarantee `include_terminal_frame_events` already
3189    /// gives terminal frames.
3190    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3191    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3192    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3193    /// broadcast rather than a ride on the durable `event_tx` history. A
3194    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3195    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3196    /// finite capacity and its willingness to silently evict a lagging
3197    /// subscriber's backlog is exactly how the call vanished with neither
3198    /// side ever logging it (see `publish_transient`'s own doc).
3199    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3200    terminal_frame_watermarks:
3201        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3202    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3203    pty_input_wake: tokio::sync::Notify,
3204    /// Per session: when the last PTY input was dispatched (latency probe +
3205    /// a short fast-poll window so the echo is published within ~1ms).
3206    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3207    next_connection_id: AtomicU64,
3208    next_instance_id: AtomicU64,
3209    next_command_id: AtomicU64,
3210    shutdown: AtomicBool,
3211    shutdown_notify: Notify,
3212    preauth_slots: Arc<Semaphore>,
3213    authenticated_slots: Arc<Semaphore>,
3214    inspection_slots: Arc<Semaphore>,
3215    mutation_gate: AsyncMutex<()>,
3216    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3217    session_records: Mutex<SessionRecords>,
3218    state_path: Option<PathBuf>,
3219    persistence_error: RwLock<Option<String>>,
3220    state_transaction: Mutex<()>,
3221    input_settle_timeout_ms: u64,
3222    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3223    /// comment. Written once per iteration by
3224    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3225    drive_loop_profile: Mutex<DriveLoopProfiler>,
3226    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3227    /// so an overwrite each iteration is cheap. The runtime itself is not
3228    /// reachable from here (it is owned locally by
3229    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3230    /// tick timings reach `GET /metrics`.
3231    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3232    /// The live shell-efficiency profile, shared with the per-instance
3233    /// worker loops that fold into it.
3234    ///
3235    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3236    /// profile` above keeps: producing a snapshot sorts every ring behind
3237    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3238    /// field set at construction because `NodeShared` is built before the
3239    /// `NativeRuntime` that owns the profile exists -- the drive loop
3240    /// publishes it once, before its first iteration. Unset means the
3241    /// runtime has not started driving yet, which `GET /metrics` reports as
3242    /// empty series rather than as zeroes it never measured.
3243    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3244    /// Physical PTY sessions the native runtime currently owns
3245    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3246    /// loop iteration -- deliberately a separate count from
3247    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3248    /// session bookkeeping), since the two can disagree and the gap is
3249    /// itself a diagnostic signal `GET /metrics` exposes.
3250    native_session_gauge: AtomicUsize,
3251    /// Lifetime totals for the per-connection serve loop, summed across
3252    /// every live connection.
3253    ///
3254    /// The drive loop's own phase timings account for about ten
3255    /// microseconds an iteration on an idle node, yet the process burns a
3256    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3257    /// with none, 23% with two, measured). That gap is somewhere in
3258    /// serving a connection, and there was no way to see it: iterations
3259    /// says whether the loop is spinning rather than sleeping, and events
3260    /// says whether it is spinning over real work or over nothing.
3261    connection_loop_iterations: AtomicU64,
3262    connection_events_sent: AtomicU64,
3263    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3264    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3265    /// `NativeRuntime::tick` observations AND forwarded zero control
3266    /// events; see the exact "produced nothing" definition at the site in
3267    /// `drive_runtime_until_shutdown` that increments these. Read twice
3268    /// and divide by the interval, same rule as `connections` above --
3269    /// `idle / total` is backlog item 5's "wake on work" number.
3270    /// Lifetime count of control commands the kernel refused.
3271    rejected_commands_total: AtomicU64,
3272    /// The named reason behind a recent kernel rejection, by the
3273    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3274    /// command -- populated by `drive_runtime_until_shutdown` for every
3275    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3276    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3277    /// `CommandId` among them.
3278    ///
3279    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3280    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3281    /// problem -- full or disconnected -- never for the kernel refusing the
3282    /// command's content (an unsupported transport, an unknown agent, ...);
3283    /// that verdict exists only once `NativeRuntime::tick` has processed the
3284    /// command, one drive-loop iteration later, in a different task than
3285    /// whichever caller is awaiting the spawn. Without this map, a spawn
3286    /// wait loop that polls `self.handle.snapshot()` for a session that
3287    /// will now never appear has no way to learn why -- it can only spin
3288    /// until its own commit deadline and report a generic "did not commit"
3289    /// timeout, discarding a reason the kernel already named precisely (see
3290    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3291    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3292    drive_loop_iterations_total: AtomicU64,
3293    drive_loop_iterations_idle: AtomicU64,
3294    /// The operator-chosen age/keep-N policy the retention sweep applies to
3295    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3296    /// see `SessionRecordRetentionConfig`'s own doc comment.
3297    session_record_retention: SessionRecordRetentionConfig,
3298    /// Lifetime count of managed session records the retention sweep has
3299    /// retired. Same tier as `rejected_commands_total` above: a live,
3300    /// process-lifetime instrument, never persisted and never reloaded from
3301    /// `state-v1.json` -- a restart legitimately resets it, matching every
3302    /// other `*_total` counter here.
3303    retired_records_total: AtomicU64,
3304    #[cfg(feature = "fixture")]
3305    fixture_semantic_hook_policy: bool,
3306    #[cfg(feature = "fixture")]
3307    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3308}
3309
3310impl Drop for NodeShared {
3311    fn drop(&mut self) {
3312        SessionLifecycleHook::on_shutdown(self);
3313    }
3314}
3315
3316#[derive(Clone, Debug, Eq, PartialEq)]
3317struct SessionBinding {
3318    workspace_id: WorkspaceId,
3319    generation: SessionGeneration,
3320    runtime_policy: ProviderRuntimePolicy,
3321    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3322    record_id: Option<SessionRecordId>,
3323    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3324    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3325    bundle: Option<ResolvedBundleReceipt>,
3326    context: Option<ResolvedContextPackReceipt>,
3327    materialization_id: Option<MaterializationId>,
3328}
3329
3330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3331enum SpawnRecordPolicy {
3332    ProviderIdentityOnly,
3333    Always,
3334}
3335
3336struct NativeEnvironmentSelectionGuard {
3337    control: Option<NativeLaunchProfileControl>,
3338    instance_id: AgentInstanceId,
3339}
3340
3341struct NativeInstanceOverlayGuard {
3342    control: Option<NativeLaunchProfileControl>,
3343    instance_id: AgentInstanceId,
3344}
3345
3346struct NativeHarnessMcpOverlayGuard {
3347    control: NativeLaunchProfileControl,
3348    instance_id: AgentInstanceId,
3349}
3350
3351impl NativeHarnessMcpOverlayGuard {
3352    fn retain(self) { std::mem::forget(self); }
3353}
3354
3355impl Drop for NativeHarnessMcpOverlayGuard {
3356    fn drop(&mut self) {
3357        self.control
3358            .clear_native_mcp_server_launch_overlay(self.instance_id);
3359    }
3360}
3361
3362impl NativeInstanceOverlayGuard {
3363    fn retain(mut self) {
3364        self.control = None;
3365    }
3366}
3367
3368impl Drop for NativeInstanceOverlayGuard {
3369    fn drop(&mut self) {
3370        if let Some(control) = self.control.as_ref() {
3371            control.clear_native_instance_launch_overlay(self.instance_id);
3372        }
3373    }
3374}
3375
3376enum PreparedNativeLaunchOverlay {
3377    Environment(NativeLaunchEnvironmentOverlay),
3378    Instance(NativeInstanceLaunchOverlay),
3379}
3380
3381impl NativeEnvironmentSelectionGuard {
3382    fn retain(mut self) {
3383        self.control = None;
3384    }
3385}
3386
3387impl Drop for NativeEnvironmentSelectionGuard {
3388    fn drop(&mut self) {
3389        if let Some(control) = self.control.as_ref() {
3390            control.clear_native_launch_profile_selection(self.instance_id);
3391        }
3392    }
3393}
3394
3395struct SessionMaterializationGuard<'a> {
3396    shared: &'a NodeShared,
3397    id: Option<MaterializationId>,
3398    /// Absolute ProviderHome (`home/`) under the session materialization root.
3399    /// Used for Claude station network `--settings` sidecar writes.
3400    provider_home: Option<PathBuf>,
3401}
3402
3403impl SessionMaterializationGuard<'_> {
3404    fn id(&self) -> Option<&MaterializationId> {
3405        self.id.as_ref()
3406    }
3407
3408    fn provider_home(&self) -> Option<&Path> {
3409        self.provider_home.as_deref()
3410    }
3411
3412    fn retain(mut self) {
3413        self.id = None;
3414    }
3415}
3416
3417impl Drop for SessionMaterializationGuard<'_> {
3418    fn drop(&mut self) {
3419        if let Some(id) = self.id.take() {
3420            let _ = self.shared.cleanup_materialization(&id);
3421        }
3422    }
3423}
3424
3425
3426/// Port: product-side reactions to session lifecycle transitions
3427/// (station lease, harness MCP overlay/registry, environment launch overlays).
3428trait SessionLifecycleHook {
3429    /// A binding was removed (unbind or spawn rollback).
3430    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3431    /// A session reached a terminal control event that revokes its harness MCP door.
3432    fn on_session_ended(&self, address: &SessionAddress);
3433    /// Node shutdown (or drop): revoke every harness MCP reservation.
3434    fn on_shutdown(&self);
3435    /// A harness MCP reservation was aborted before activation.
3436    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3437    /// Release session-owned materialization for a removed binding.
3438    fn cleanup_session_materialization(
3439        &self,
3440        address: &SessionAddress,
3441        binding: &SessionBinding,
3442    ) -> Result<(), NodeFailure>;
3443}
3444
3445impl SessionLifecycleHook for NodeShared {
3446    fn on_session_ended(&self, address: &SessionAddress) {
3447        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3448            registry.revoke_session(address);
3449        }
3450        if let Some(control) = self.native_launch_profile_control.as_ref() {
3451            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3452        }
3453    }
3454
3455    fn on_shutdown(&self) {
3456        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3457            let instances = registry.shutdown();
3458            if let Some(control) = self.native_launch_profile_control.as_ref() {
3459                for instance_id in instances {
3460                    control.clear_native_mcp_server_launch_overlay(instance_id);
3461                }
3462            }
3463        }
3464    }
3465
3466    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3467        if let Some(control) = self.native_launch_profile_control.as_ref() {
3468            control.clear_native_mcp_server_launch_overlay(instance_id);
3469        }
3470    }
3471
3472    fn cleanup_session_materialization(
3473        &self,
3474        address: &SessionAddress,
3475        binding: &SessionBinding,
3476    ) -> Result<(), NodeFailure> {
3477        self.cleanup_session_owned_materialization(address, binding)
3478    }
3479
3480    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3481        // Exclusive dig2browser station profile lease is node-local; clear
3482        // on every session unbind / rollback. Never ImportSession/cookies.
3483        #[cfg(feature = "dig2-station-probe")]
3484        {
3485            crate::dig2_station_lease::release_lease_for_holder(
3486                &self.browser_station_leases,
3487                address.session.instance_id,
3488            );
3489        }
3490        if let Some(control) = self.native_launch_profile_control.as_ref() {
3491            control.clear_native_mcp_server_launch_overlay(
3492                address.session.instance_id,
3493            );
3494        }
3495        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3496            registry.revoke_session(address);
3497        }
3498        if removed
3499            .is_some_and(|binding| {
3500                binding.environment_profile.is_some()
3501                    || binding.bundle.is_some()
3502                    || binding.context.is_some()
3503            })
3504        {
3505            if let Some(control) = self.native_launch_profile_control.as_ref() {
3506                control.clear_native_launch_profile_selection(
3507                    address.session.instance_id,
3508                );
3509                control.clear_native_instance_launch_overlay(
3510                    address.session.instance_id,
3511                );
3512            }
3513        }
3514    }
3515}
3516
3517struct SpawnPrepareRequest<'r> {
3518    instance_id: AgentInstanceId,
3519    address: &'r SessionAddress,
3520    provider: &'r AgentId,
3521    mode: SessionMode,
3522    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3523    bundle: Option<&'r ResolvedBundleReceipt>,
3524    context: Option<&'r ResolvedContextPackReceipt>,
3525    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3526    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3527    approval_level: ApprovalLevel,
3528    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3529    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3530}
3531
3532/// Guards held between "session identity minted" and "launch committed".
3533/// Field order is the drop order and matches the pre-refactor local order
3534/// (instance overlay, environment selection, materialization, harness, station lease).
3535struct PreparedSpawn<'a> {
3536    instance_overlay: Option<NativeInstanceOverlayGuard>,
3537    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3538    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3539    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3540    #[cfg(feature = "dig2-station-probe")]
3541    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3542    materialization_id: Option<MaterializationId>,
3543}
3544
3545impl PreparedSpawn<'_> {
3546    /// Session-owned launch state now belongs to the binding.
3547    fn retain_session_guards(&mut self) {
3548        if let Some(selection) = self.environment_selection.take() {
3549            selection.retain();
3550        }
3551        if let Some(overlay) = self.instance_overlay.take() {
3552            overlay.retain();
3553        }
3554        if let Some(guard) = self.materialization_guard.take() {
3555            guard.retain();
3556        }
3557    }
3558
3559    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3560    fn retain_launch_guards(&mut self) {
3561        if let Some(overlay) = self.harness_mcp_overlay.take() {
3562            overlay.retain();
3563        }
3564        #[cfg(feature = "dig2-station-probe")]
3565        if let Some(guard) = self.browser_station_lease_guard.take() {
3566            guard.retain();
3567        }
3568    }
3569}
3570
3571/// Port: everything product-side that must be prepared before a session is
3572/// bound and started (station lease, harness MCP overlay, materialization,
3573/// environment selection, network overlays, launch overlay).
3574trait SpawnPreparer {
3575    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3576    /// Select the native launch profile for an environment-profile session.
3577    fn select_environment(
3578        &self,
3579        instance_id: AgentInstanceId,
3580        provider: &AgentId,
3581        mode: SessionMode,
3582        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3583    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3584    /// Install a prepared launch overlay for the instance.
3585    fn install_launch_overlay(
3586        &self,
3587        instance_id: AgentInstanceId,
3588        overlay: PreparedNativeLaunchOverlay,
3589    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3590}
3591
3592impl SpawnPreparer for NodeShared {
3593    fn select_environment(
3594        &self,
3595        instance_id: AgentInstanceId,
3596        provider: &AgentId,
3597        mode: SessionMode,
3598        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3599    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3600        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3601    }
3602
3603    fn install_launch_overlay(
3604        &self,
3605        instance_id: AgentInstanceId,
3606        overlay: PreparedNativeLaunchOverlay,
3607    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3608        self.install_prepared_launch_overlay(instance_id, overlay)
3609    }
3610
3611    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3612        let SpawnPrepareRequest {
3613            instance_id,
3614            address,
3615            provider,
3616            mode,
3617            environment_profile,
3618            bundle,
3619            context,
3620            managed_authority,
3621            harness_mcp,
3622            approval_level,
3623            network_allowlist,
3624            browser_profile_id,
3625        } = request;
3626        // Track A follow-on: exclusive node-local BrowserStationLease after
3627        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3628        // never ImportSession / cookies on C2. Linux/feature-off: probe
3629        // already refused or stub; this block is cfg'd out when feature off.
3630        #[cfg(feature = "dig2-station-probe")]
3631        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3632            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3633            match acquire_lease(
3634                &self.browser_station_leases,
3635                profile_id,
3636                instance_id,
3637                unix_time_ms(),
3638            ) {
3639                Ok(guard) => Some(guard),
3640                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3641                    return Err(failure(
3642                        NodeFailureCode::BrowserStationProfileBusy,
3643                        "dig2browser station profile is already leased by another session",
3644                    ));
3645                }
3646                Err(BrowserStationLeaseError::CapacityExceeded) => {
3647                    return Err(failure(
3648                        NodeFailureCode::BrowserStationProfileBusy,
3649                        "dig2browser station profile lease capacity exhausted",
3650                    ));
3651                }
3652                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3653                    return Err(failure(
3654                        NodeFailureCode::BrowserStationProfileBusy,
3655                        "session already holds a dig2browser station profile lease",
3656                    ));
3657                }
3658            }
3659        } else {
3660            None
3661        };
3662        #[cfg(not(feature = "dig2-station-probe"))]
3663        let _ = browser_profile_id;
3664        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3665            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3666            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3667            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3668            // both read the identical overlay resolved below, one as child
3669            // environment, the other translated into one stdio MCP server
3670            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3671            // only, and nothing downstream reads a harness-MCP overlay for
3672            // that transport yet.
3673            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3674            {
3675                return Err(failure(
3676                    NodeFailureCode::BindingMismatch,
3677                    "harness MCP reservation does not match the exact PTY/ACP provider",
3678                ));
3679            }
3680            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3681            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3682                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3683            })?;
3684            // Every name below is the caller's, carried in the reservation's
3685            // launch description: which environment variables receive the
3686            // endpoint, token and helper program, what the MCP server is called,
3687            // which argv it gets and which stale variables are erased.
3688            let launch = prepared.launch();
3689            let mut env_entries = vec![
3690                (
3691                    OsString::from(launch.endpoint_env.as_str()),
3692                    prepared.endpoint().as_os_str().to_os_string(),
3693                ),
3694                (
3695                    OsString::from(launch.token_env.as_str()),
3696                    prepared.token().expose().into(),
3697                ),
3698                (
3699                    OsString::from(launch.program_env.as_str()),
3700                    prepared.helper_program().as_os_str().to_os_string(),
3701                ),
3702            ];
3703            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3704                env_entries.push(trace_entry);
3705            }
3706            if let Some(door) = crate::door::door_listen_addr() {
3707                env_entries.push((
3708                    OsString::from("HATCHERY_NODE_DOOR"),
3709                    OsString::from(door.to_string()),
3710                ));
3711                // Same key shape as HQ browser/draft ids:
3712                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3713                // slot across node boots when the instance counter resets.
3714                let session_key = format!(
3715                    "pty-{}-{}-{}",
3716                    self.incarnation_id,
3717                    address.workspace_id.as_str(),
3718                    address.session.instance_id.0
3719                );
3720                env_entries.push((
3721                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3722                    OsString::from(session_key),
3723                ));
3724            }
3725            let overlay = NativeMcpServerLaunchOverlay::new(
3726                provider.clone(),
3727                launch.server_name.as_str(),
3728                prepared.helper_program().as_os_str().to_os_string(),
3729                launch.args.iter().map(OsString::from).collect(),
3730                env_entries,
3731                launch.scrub_env.iter().map(OsString::from).collect(),
3732            ).map_err(|_| failure(
3733                NodeFailureCode::HarnessMcpUnavailable,
3734                "harness MCP launch overlay is unavailable",
3735            ))?;
3736            control
3737                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3738                .map_err(|_| failure(
3739                    NodeFailureCode::HarnessMcpUnavailable,
3740                    "harness MCP launch overlay could not be installed",
3741                ))?;
3742            // State-change line: the overlay is installed and will be read
3743            // back by the PTY child's environment or, for ACP,
3744            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3745            // time -- no secrets here, only which instance/provider/mode
3746            // now carries a harness-MCP door.
3747            tracing::info!(
3748                node_id = %self.node_id,
3749                instance_id = ?instance_id,
3750                provider = %provider,
3751                mode = ?mode,
3752                "harness MCP overlay installed for spawn",
3753            );
3754            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3755        } else {
3756            None
3757        };
3758        let prepared_materialization = self.prepare_session_materialization(
3759            &address,
3760            &provider,
3761            mode,
3762            environment_profile,
3763            bundle,
3764            context,
3765            managed_authority.cloned(),
3766        )?;
3767        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3768            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3769            None => (None, None),
3770        };
3771        let materialization_id = materialization_guard
3772            .as_ref()
3773            .and_then(SessionMaterializationGuard::id)
3774            .cloned();
3775        let mut environment_selection = SpawnPreparer::select_environment(self, 
3776            instance_id,
3777            &provider,
3778            mode,
3779            environment_profile,
3780        )?;
3781        // Station network overlays: Codex `-c network_access` and/or Claude
3782        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3783        let provider_home = materialization_guard
3784            .as_ref()
3785            .and_then(SessionMaterializationGuard::provider_home);
3786        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3787            &provider,
3788            mode,
3789            approval_level,
3790            network_allowlist,
3791            provider_home,
3792        )?;
3793        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3794            (prepared, args) if args.is_empty() => prepared,
3795            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3796                overlay.append_extra_args(args).map_err(|_| {
3797                    failure(
3798                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3799                        "network allowlist overlay could not merge with instance launch args",
3800                    )
3801                })?;
3802                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3803            }
3804            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3805                Some(PreparedNativeLaunchOverlay::Instance(
3806                    overlay
3807                        .into_instance_with_extra_args(args)
3808                        .map_err(|_| {
3809                            failure(
3810                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3811                                "network allowlist overlay could not merge with environment overlay",
3812                            )
3813                        })?,
3814                ))
3815            }
3816            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3817                NativeInstanceLaunchOverlay::new(
3818                    provider.clone(),
3819                    match mode {
3820                        SessionMode::Pty => TransportKind::Pty,
3821                        SessionMode::Acp => TransportKind::Acp,
3822                        SessionMode::Inline => TransportKind::Pipe,
3823                    },
3824                    Vec::new(),
3825                    args,
3826                )
3827                .map_err(|_| {
3828                    failure(
3829                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3830                        "network allowlist overlay is invalid for this transport",
3831                    )
3832                })?,
3833            )),
3834        };
3835        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3836        // overlay or appended to the overlay the spawn already carries.
3837        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3838            (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3839                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3840                match accounts.provision(m4a_provider, &session_key) {
3841                    Ok(account) => {
3842                        let identity = account.env_mutations();
3843                        let merged = match environment_overlay {
3844                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3845                                .map(PreparedNativeLaunchOverlay::Instance),
3846                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3847                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3848                            }
3849                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3850                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3851                            }
3852                        }
3853                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3854                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3855                        self.m4a_live
3856                            .lock()
3857                            .unwrap_or_else(|p| p.into_inner())
3858                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3859                        Some(merged)
3860                    }
3861                    Err(error) => {
3862                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3863                        environment_overlay
3864                    }
3865                }
3866            }
3867            (SessionMode::Pty, Some(_), None) => {
3868                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3869                environment_overlay
3870            }
3871            _ => environment_overlay,
3872        };
3873        let mut instance_overlay = environment_overlay
3874            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3875            .transpose()?
3876            .flatten();
3877        Ok(PreparedSpawn {
3878            instance_overlay,
3879            environment_selection,
3880            materialization_guard,
3881            harness_mcp_overlay,
3882            #[cfg(feature = "dig2-station-probe")]
3883            browser_station_lease_guard,
3884            materialization_id,
3885        })
3886    }
3887}
3888
3889struct SessionRecords {
3890    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3891}
3892
3893struct SpawnIdempotencyCache {
3894    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3895}
3896
3897struct SpawnIdempotencyEntry {
3898    value: SpawnIdempotencyValue,
3899    expires_at: Instant,
3900}
3901
3902enum SpawnIdempotencyValue {
3903    Standard {
3904        spec: SpawnSpec,
3905        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3906    },
3907    Managed {
3908        request: ManagedWorktreeSpawnRequest,
3909        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3910    },
3911    HarnessMcp,
3912}
3913
3914enum DurableManagedSpawnReplayDecision {
3915    Reserved,
3916    PendingLinked,
3917    Committed(ManagedWorktreeSpawnReceipt),
3918}
3919
3920impl SpawnIdempotencyValue {
3921    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3922        match self {
3923            Self::Standard { result, .. } => result
3924                .as_ref()
3925                .ok()
3926                .and_then(|receipt| receipt.context.as_ref()),
3927            Self::Managed { result, .. } => result
3928                .as_ref()
3929                .ok()
3930                .and_then(|receipt| receipt.spawn.context.as_ref()),
3931            Self::HarnessMcp => None,
3932        }
3933        .is_some_and(|receipt| &receipt.id == context_id)
3934    }
3935}
3936
3937#[derive(Clone, Copy)]
3938struct ControllerLease {
3939    connection_id: u64,
3940    expires_at: Instant,
3941}
3942
3943struct NodeEventHistory {
3944    last_sequence: u64,
3945    replay_floor_sequence: u64,
3946    events: VecDeque<NodeEventEnvelope>,
3947    record_providers: BTreeMap<SessionRecordId, AgentId>,
3948    removed_record_providers: BTreeMap<u64, AgentId>,
3949}
3950
3951impl NodeEventHistory {
3952    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3953        Self {
3954            last_sequence: 0,
3955            replay_floor_sequence: 1,
3956            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3957            record_providers,
3958            removed_record_providers: BTreeMap::new(),
3959        }
3960    }
3961}
3962
3963impl NodeShared {
3964    fn new_with_incarnation(
3965        handle: Gate4AgentHandle,
3966        access_token: String,
3967        node_id: NodeId,
3968        incarnation_id: NodeIncarnationId,
3969        workspaces: Vec<WorkspaceConfig>,
3970        enabled_providers: Vec<AgentId>,
3971        provider_runtime_statuses: ProviderRuntimeStatuses,
3972        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3973        provider_contracts: Vec<ProviderContractSupport>,
3974        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3975        spawn_profiles: SpawnProfileRegistry,
3976        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3977        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3978        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3979        state_path: Option<PathBuf>,
3980        records: Vec<ManagedSessionRecord>,
3981        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3982        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3983        materializations: Vec<MaterializationOwnershipRecord>,
3984        persistence_warning: Option<String>,
3985        input_settle_timeout_ms: u64,
3986    ) -> Self {
3987        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3988        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3989        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3990        let (harness_mcp_proxy_event_tx, _) =
3991            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3992        let record_providers = records
3993            .iter()
3994            .map(|record| (record.record_id.clone(), record.provider.clone()))
3995            .collect();
3996        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3997            .iter()
3998            .map(|workspace| {
3999                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4000            })
4001            .collect();
4002        for lease in &managed_worktrees {
4003            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4004        }
4005        let managed_worktree_profiles = workspaces.iter()
4006            .map(|workspace| (
4007                workspace.workspace_id.clone(),
4008                workspace.managed_worktree_profiles.clone(),
4009            ))
4010            .collect();
4011        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4012            managed_worktrees,
4013            managed_worktree_tombstones,
4014        ).expect("durable managed worktree registry was validated while loading");
4015        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4016        managed_worktrees.reattach_record_holders(
4017            &managed_worktree_record_holders(&records),
4018            unix_time_ms(),
4019        );
4020        Self {
4021            handle,
4022            access_token,
4023            node_id,
4024            incarnation_id,
4025            started_at_unix_ms: std::time::SystemTime::now()
4026                .duration_since(std::time::UNIX_EPOCH)
4027                .unwrap_or_default()
4028                .as_millis()
4029                .min(u64::MAX as u128) as u64,
4030            workspaces: RwLock::new(
4031                workspaces
4032                    .into_iter()
4033                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4034                    .collect(),
4035            ),
4036            worktree_service_modes,
4037            managed_worktree_profiles,
4038            managed_worktrees: Mutex::new(managed_worktrees),
4039            enabled_providers,
4040            provider_runtime_statuses,
4041            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4042            provider_runtime_monitor,
4043            provider_contracts,
4044            provider_adapter_contracts,
4045            spawn_profiles,
4046            environment_profiles: RwLock::new(BTreeMap::new()),
4047            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4048            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4049            #[cfg(feature = "dig2-station-probe")]
4050            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4051            bundle_catalog: RwLock::new(BundleCatalog::default()),
4052            delivery_store: Mutex::new(None),
4053            context_pack_store: Mutex::new(None),
4054            context_catalog: RwLock::new(ContextPackCatalog::default()),
4055            native_launch_profile_control,
4056            harness_mcp_registry: None,
4057            native_session_catalog,
4058            session_environment_materializer,
4059            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4060            m4a_live: Mutex::new(BTreeMap::new()),
4061            materializations: Mutex::new(
4062                materializations
4063                    .into_iter()
4064                    .map(|record| (record.id().clone(), record))
4065                    .collect(),
4066            ),
4067            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4068                entries: BTreeMap::new(),
4069            }),
4070            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4071            controller: Mutex::new(None),
4072            provisional_spawns: Mutex::new(BTreeMap::new()),
4073            history: Mutex::new(NodeEventHistory::new(record_providers)),
4074            event_tx,
4075            terminal_event_tx,
4076            agent_stream_event_tx,
4077            harness_mcp_proxy_event_tx,
4078            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4079            pty_input_wake: tokio::sync::Notify::new(),
4080            pty_input_marks: Mutex::new(BTreeMap::new()),
4081            next_connection_id: AtomicU64::new(1),
4082            next_instance_id: AtomicU64::new(1),
4083            next_command_id: AtomicU64::new(1),
4084            shutdown: AtomicBool::new(false),
4085            shutdown_notify: Notify::new(),
4086            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4087            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4088            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4089            mutation_gate: AsyncMutex::new(()),
4090            session_bindings: Mutex::new(BTreeMap::new()),
4091            session_records: Mutex::new(SessionRecords {
4092                records: records
4093                    .into_iter()
4094                    .map(|record| (record.record_id.clone(), record))
4095                    .collect(),
4096            }),
4097            state_path,
4098            persistence_error: RwLock::new(
4099                persistence_warning
4100                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4101            ),
4102            state_transaction: Mutex::new(()),
4103            input_settle_timeout_ms,
4104            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4105            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4106            shell_efficiency_profile: OnceLock::new(),
4107            native_session_gauge: AtomicUsize::new(0),
4108            connection_loop_iterations: AtomicU64::new(0),
4109            connection_events_sent: AtomicU64::new(0),
4110            rejected_commands_total: AtomicU64::new(0),
4111            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4112            drive_loop_iterations_total: AtomicU64::new(0),
4113            drive_loop_iterations_idle: AtomicU64::new(0),
4114            session_record_retention: SessionRecordRetentionConfig::default(),
4115            retired_records_total: AtomicU64::new(0),
4116            #[cfg(feature = "fixture")]
4117            fixture_semantic_hook_policy: false,
4118            #[cfg(feature = "fixture")]
4119            fixture_spawn_managed_worktree_v2_failure_probe:
4120                SpawnManagedWorktreeV2FailureProbe::default(),
4121        }
4122    }
4123
4124    #[cfg(test)]
4125    fn new(
4126        handle: Gate4AgentHandle,
4127        access_token: String,
4128        node_id: NodeId,
4129        workspaces: Vec<WorkspaceConfig>,
4130        enabled_providers: Vec<AgentId>,
4131    ) -> Self {
4132        Self::new_with_incarnation(
4133            handle,
4134            access_token,
4135            node_id,
4136            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4137            workspaces,
4138            enabled_providers,
4139            ProviderRuntimeStatuses::default(),
4140            None,
4141            Vec::new(),
4142            Vec::new(),
4143            SpawnProfileRegistry::default(),
4144            None,
4145            None,
4146            None,
4147            None,
4148            Vec::new(),
4149            Vec::new(),
4150            Vec::new(),
4151            Vec::new(),
4152            None,
4153            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4154        )
4155    }
4156
4157    fn persistence_error(&self) -> Option<String> {
4158        self.persistence_error
4159            .read()
4160            .unwrap_or_else(|poisoned| poisoned.into_inner())
4161            .clone()
4162    }
4163
4164    async fn admit_provider_runtime(
4165        &self,
4166        provider: &AgentId,
4167        requirement: ProviderRuntimeRequirement,
4168    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4169        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4170            let pipe_transport = monitor.supports_pipe_transport(provider);
4171            let acp_transport = monitor.supports_acp_transport(provider);
4172            let provider = provider.clone();
4173            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4174            let (status, admission) = timeout(
4175                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4176                refresh,
4177            )
4178            .await
4179            .map_err(|_| failure(
4180                NodeFailureCode::BackendBusy,
4181                "provider runtime probe exceeded its bounded deadline",
4182            ))?
4183            .map_err(|_| failure(
4184                NodeFailureCode::BackendOperationFailed,
4185                "provider runtime probe task failed",
4186            ))?;
4187            if let Some(status) = status {
4188                self
4189                    .provider_runtime_status_updates
4190                    .write()
4191                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4192                    .insert(status.provider().clone(), status);
4193            }
4194            admission.and_then(|policy| {
4195                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4196            })
4197        } else {
4198            // No live monitor means no catalog to consult -- there is
4199            // nothing here to confirm this provider declares a Pipe or ACP
4200            // transport, so Inline/Acp are rejected the same conservative
4201            // way an undeclared provider always was, rather than admitted
4202            // on trust.
4203            crate::provider_runtime::admit_status(
4204                &self.provider_runtime_statuses,
4205                provider,
4206                false,
4207                false,
4208                requirement,
4209            )
4210        };
4211        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4212        admission.map_err(|error| match error {
4213            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4214                NodeFailureCode::BackendOperationFailed,
4215                "provider launcher is unavailable",
4216            ),
4217            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4218                NodeFailureCode::UnsupportedCapability,
4219                "provider semantic capability is not verified",
4220            ),
4221            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4222                NodeFailureCode::BackendBusy,
4223                "provider runtime probe is already in progress",
4224            ),
4225        })
4226    }
4227
4228    /// Whether the catalog declares a Pipe transport for `provider` --
4229    /// the fact `require_policy` needs to admit or reject
4230    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4231    /// catalog to consult, so this stays conservative and rejects, matching
4232    /// `admit_provider_runtime`'s own no-monitor branch.
4233    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4234        match self.provider_runtime_monitor.as_deref() {
4235            Some(monitor) => monitor.supports_pipe_transport(provider),
4236            None => false,
4237        }
4238    }
4239
4240    /// Whether the catalog declares an ACP transport for `provider` -- the
4241    /// fact `require_policy` needs to admit or reject
4242    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4243    /// `provider_supports_pipe_transport` exactly, including the
4244    /// conservative no-monitor default.
4245    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4246        match self.provider_runtime_monitor.as_deref() {
4247            Some(monitor) => monitor.supports_acp_transport(provider),
4248            None => false,
4249        }
4250    }
4251
4252    /// Grants hook ingestion to a provider whose catalog entry declares a
4253    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4254    ///
4255    /// `admit_status`'s `RawPassthrough` arm hands back
4256    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4257    /// correct for everything it was written to answer, and wrong for hooks
4258    /// specifically. A hook event is not inferred from terminal behaviour,
4259    /// so it has nothing to do with whether a launcher probe verified this
4260    /// CLI version's PTY contract; the authority is the catalog declaring
4261    /// the adapter plus the ingress authenticating the route. Without this
4262    /// the node installs a provider's hooks, the CLI faithfully calls them,
4263    /// and every event is refused -- which is exactly the state grok was in.
4264    ///
4265    /// Applied to both admission paths so the monitor's own derivation and
4266    /// this coarse fallback cannot disagree about the same provider.
4267    fn admit_declared_hook_semantics(
4268        &self,
4269        provider: &AgentId,
4270        policy: ProviderRuntimePolicy,
4271    ) -> ProviderRuntimePolicy {
4272        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4273            return policy;
4274        }
4275        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4276            contract.provider == *provider && contract.family == AdapterFamily::Hook
4277        });
4278        if !declares_hook {
4279            return policy;
4280        }
4281        ProviderRuntimePolicy::new(
4282            policy.raw_pty_lifecycle,
4283            policy.semantic_readiness,
4284            policy.structured_prompt,
4285            policy.provider_session_identity,
4286            policy.semantic_resume,
4287            true,
4288        )
4289        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4290    }
4291
4292    /// Station network allowlist → provider launch overlay argv.
4293    ///
4294    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4295    ///   `provider_native` under Moderate (PTY or ACP).
4296    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4297    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4298    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4299    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4300    ///   no overlay.
4301    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4302    ///   empty overlay when no `provider_native`; invented native keys already
4303    ///   refuse at load / resolve.
4304    ///
4305    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4306    fn resolve_network_allowlist_overlay_args(
4307        &self,
4308        provider: &AgentId,
4309        mode: SessionMode,
4310        approval_level: ApprovalLevel,
4311        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4312        provider_home: Option<&Path>,
4313    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4314        let Some(allowlist_id) = network_allowlist else {
4315            return Ok(Vec::new());
4316        };
4317        let catalog = self
4318            .network_allowlist_catalog
4319            .read()
4320            .unwrap_or_else(|poisoned| poisoned.into_inner());
4321        let Some(entry) = catalog.get(allowlist_id) else {
4322            return Err(failure(
4323                NodeFailureCode::UnknownNetworkAllowlist,
4324                "spawn network allowlist is unavailable on this node",
4325            ));
4326        };
4327
4328        // Claude: settings-shaped overlay from permits (not provider_native).
4329        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4330            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4331                |message| {
4332                    failure(
4333                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4334                        &message,
4335                    )
4336                },
4337            )?;
4338            if !matches!(mode, SessionMode::Pty) {
4339                return Err(failure(
4340                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4341                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4342                ));
4343            }
4344            let Some(home) = provider_home else {
4345                return Err(failure(
4346                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4347                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4348                ));
4349            };
4350            let domains =
4351                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4352            if domains.is_empty() {
4353                return Err(failure(
4354                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4355                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4356                ));
4357            }
4358            let settings_path = home.join(
4359                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4360            );
4361            let json =
4362                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4363            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4364                |_| {
4365                    failure(
4366                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4367                        "Claude station network settings sidecar could not be written under ProviderHome",
4368                    )
4369                },
4370            )?;
4371            let overlay =
4372                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4373            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4374        }
4375
4376        let Some(native) = entry.provider_native.as_ref() else {
4377            return Ok(Vec::new());
4378        };
4379        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4380            provider.as_str(),
4381            approval_level,
4382            native,
4383        )
4384        .map_err(|message| {
4385            failure(
4386                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4387                &message,
4388            )
4389        })?;
4390        if overlay.is_empty() {
4391            return Ok(Vec::new());
4392        }
4393        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4394            return Err(failure(
4395                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4396                "Codex network_access -c overlay requires PTY or ACP transport",
4397            ));
4398        }
4399        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4400    }
4401
4402    /// Backward-compatible alias used by Codex-focused unit tests.
4403    fn resolve_codex_network_access_overlay_args(
4404        &self,
4405        provider: &AgentId,
4406        mode: SessionMode,
4407        approval_level: ApprovalLevel,
4408        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4409    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4410        self.resolve_network_allowlist_overlay_args(
4411            provider,
4412            mode,
4413            approval_level,
4414            network_allowlist,
4415            None,
4416        )
4417    }
4418
4419    fn resolve_environment_profile(
4420        &self,
4421        resolved: &ResolvedSpawnSpec,
4422    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4423        // Station network allowlist catalog enforce (plan
4424        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4425        // catalog — unknown ids refuse rather than silent ambient. Runs even
4426        // when no environment_profile_id is present so a bare allowlist still
4427        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4428        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4429            let catalog = self
4430                .network_allowlist_catalog
4431                .read()
4432                .unwrap_or_else(|poisoned| poisoned.into_inner());
4433            let Some(entry) = catalog.get(allowlist_id) else {
4434                return Err(failure(
4435                    NodeFailureCode::UnknownNetworkAllowlist,
4436                    "spawn network allowlist is unavailable on this node",
4437                ));
4438            };
4439            // Track B: refuse provider-native mappings this provider cannot
4440            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4441            if let Some(native) = entry.provider_native.as_ref() {
4442                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4443                    resolved.provider.as_str(),
4444                    resolved.approval_level,
4445                    native,
4446                ) {
4447                    return Err(failure(
4448                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4449                        &message,
4450                    ));
4451                }
4452            }
4453        }
4454        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4455        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4456        // cheap named-pipe path reachability must succeed (Windows
4457        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4458        // NodeFailureCode. Runs even without environment_profile_id (same
4459        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4460        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4461        // acquired later in spawn_session_with_deadline (after instance id).
4462        #[cfg(feature = "dig2-station-probe")]
4463        if resolved.browser_profile_id.is_some() {
4464            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4465            match probe_station_reachable() {
4466                Ok(()) => {}
4467                Err(StationProbeError::UnsupportedPlatform)
4468                | Err(StationProbeError::InvalidPipeSuffix) => {
4469                    return Err(failure(
4470                        NodeFailureCode::BrowserStationProbeUnavailable,
4471                        "dig2browser station probe unavailable on this platform",
4472                    ));
4473                }
4474                Err(StationProbeError::Unreachable) => {
4475                    return Err(failure(
4476                        NodeFailureCode::BrowserStationUnreachable,
4477                        "dig2browser station named-pipe is unreachable",
4478                    ));
4479                }
4480            }
4481        }
4482        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4483            return Ok(None);
4484        };
4485        let profiles = self
4486            .environment_profiles
4487            .read()
4488            .unwrap_or_else(|poisoned| poisoned.into_inner());
4489        let binding = profiles.get(profile_id).ok_or_else(|| {
4490            failure(
4491                NodeFailureCode::UnknownEnvironmentProfile,
4492                "spawn environment profile is unavailable on this node",
4493            )
4494        })?;
4495        if binding.provider != resolved.provider
4496            || binding.native_profile_id(resolved.mode).is_none()
4497        {
4498            return Err(failure(
4499                NodeFailureCode::EnvironmentProfileBindingMismatch,
4500                "spawn environment profile does not match the provider and mode",
4501            ));
4502        }
4503        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4504        // after catalog + optional dig2 probe above. Empty/whitespace ids
4505        // already refuse at Spawn*Id construction.
4506        Ok(Some(ResolvedEnvironmentProfileReceipt {
4507            profile_id: binding.id.clone(),
4508            profile_revision: binding.revision.clone(),
4509            network_allowlist: resolved.network_allowlist.clone(),
4510            browser_profile_id: resolved.browser_profile_id.clone(),
4511        }))
4512    }
4513
4514    fn resolve_bundle(
4515        &self,
4516        resolved: &ResolvedSpawnSpec,
4517        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4518    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4519        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4520            return Ok(None);
4521        };
4522        let catalog = self.bundle_catalog
4523            .read()
4524            .unwrap_or_else(|poisoned| poisoned.into_inner());
4525        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4526                failure(
4527                    NodeFailureCode::UnknownBundle,
4528                    "spawn bundle is unavailable on this node",
4529                )
4530            })?;
4531        self.bundle_layout(
4532            &resolved.provider,
4533            resolved.mode,
4534            environment_profile,
4535            bundle,
4536        )?;
4537        Ok(Some(bundle.receipt()))
4538    }
4539
4540    fn begin_delivery_stage(
4541        &self,
4542        manifest: DeliveryBundleManifestV2,
4543    ) -> Result<NodeResponse, NodeFailure> {
4544        let mut store = self
4545            .delivery_store
4546            .lock()
4547            .unwrap_or_else(|poisoned| poisoned.into_inner());
4548        let store = store.as_mut().ok_or_else(|| {
4549            failure(
4550                NodeFailureCode::DeliveryStageStorageFailed,
4551                "node delivery store is unavailable",
4552            )
4553        })?;
4554        let begun = store.begin(manifest).map_err(delivery_failure)?;
4555        Ok(NodeResponse::DeliveryStageBegun {
4556            stage_id: begun.stage_id,
4557            manifest_digest: begun.manifest_digest,
4558            missing_blobs: begun.missing_blobs,
4559        })
4560    }
4561
4562    fn put_delivery_blob_chunk(
4563        &self,
4564        stage_id: DeliveryStageId,
4565        blob_digest: DeliveryBlobDigestV1,
4566        offset: u64,
4567        chunk_hex: DeliveryBlobChunkHexV1,
4568    ) -> Result<NodeResponse, NodeFailure> {
4569        let mut store = self
4570            .delivery_store
4571            .lock()
4572            .unwrap_or_else(|poisoned| poisoned.into_inner());
4573        let store = store.as_mut().ok_or_else(|| {
4574            failure(
4575                NodeFailureCode::DeliveryStageStorageFailed,
4576                "node delivery store is unavailable",
4577            )
4578        })?;
4579        let accepted = store
4580            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4581            .map_err(delivery_failure)?;
4582        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4583            stage_id: accepted.stage_id,
4584            blob_digest: accepted.blob_digest,
4585            next_offset: accepted.next_offset,
4586        })
4587    }
4588
4589    fn commit_delivery_stage(
4590        &self,
4591        stage_id: DeliveryStageId,
4592    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4593        let mut store = self
4594            .delivery_store
4595            .lock()
4596            .unwrap_or_else(|poisoned| poisoned.into_inner());
4597        let store = store.as_mut().ok_or_else(|| {
4598            failure(
4599                NodeFailureCode::DeliveryStageStorageFailed,
4600                "node delivery store is unavailable",
4601            )
4602        })?;
4603        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4604        let mut catalog = self
4605            .bundle_catalog
4606            .write()
4607            .unwrap_or_else(|poisoned| poisoned.into_inner());
4608        let mut next_catalog = catalog.clone();
4609        next_catalog
4610            .insert_idempotent(prepared.bundle().clone())
4611            .map_err(|_| {
4612                failure(
4613                    NodeFailureCode::DeliveryStageConflict,
4614                    "delivery bundle identity conflicts with installed content",
4615                )
4616            })?;
4617        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4618        *catalog = next_catalog;
4619        Ok(receipt)
4620    }
4621
4622    fn abort_delivery_stage(
4623        &self,
4624        stage_id: &DeliveryStageId,
4625    ) -> Result<(), NodeFailure> {
4626        let mut store = self
4627            .delivery_store
4628            .lock()
4629            .unwrap_or_else(|poisoned| poisoned.into_inner());
4630        let store = store.as_mut().ok_or_else(|| {
4631            failure(
4632                NodeFailureCode::DeliveryStageStorageFailed,
4633                "node delivery store is unavailable",
4634            )
4635        })?;
4636        store.abort(stage_id).map_err(delivery_failure)
4637    }
4638
4639    fn resolve_context(
4640        &self,
4641        resolved: &ResolvedSpawnSpec,
4642    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4643        let Some(context_id) = resolved.context_id.as_ref() else {
4644            return Ok(None);
4645        };
4646        self.context_catalog
4647            .read()
4648            .unwrap_or_else(|poisoned| poisoned.into_inner())
4649            .get(context_id)
4650            .map(|context| context.receipt().clone())
4651            .ok_or_else(|| {
4652                failure(
4653                    NodeFailureCode::UnknownContextPack,
4654                    "spawn context pack is unavailable on this node",
4655                )
4656            })
4657            .map(Some)
4658    }
4659
4660    fn bundle_layout(
4661        &self,
4662        provider: &AgentId,
4663        mode: SessionMode,
4664        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4665        bundle: &NodeBundle,
4666    ) -> Result<BundleProviderLayout, NodeFailure> {
4667        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4668            failure(
4669                NodeFailureCode::BundleBindingMismatch,
4670                "spawn bundle does not match the provider and mode",
4671            )
4672        })?;
4673        if layout != BundleProviderLayout::Codex {
4674            return Ok(layout);
4675        }
4676        let profile = environment_profile.and_then(|receipt| {
4677            let profiles = self
4678                .environment_profiles
4679                .read()
4680                .unwrap_or_else(|poisoned| poisoned.into_inner());
4681            let binding = profiles.get(&receipt.profile_id)?;
4682            if binding.revision != receipt.profile_revision
4683                || &binding.provider != provider
4684                || binding.native_profile_id(mode).is_none()
4685            {
4686                return None;
4687            }
4688            self.environment_materialization_profiles
4689                .read()
4690                .unwrap_or_else(|poisoned| poisoned.into_inner())
4691                .get(&receipt.profile_id)
4692                .cloned()
4693        });
4694        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4695            return Err(failure(
4696                NodeFailureCode::BundleBindingMismatch,
4697                "Codex bundle requires an exact isolated CODEX_HOME profile",
4698            ));
4699        }
4700        Ok(layout)
4701    }
4702
4703    fn select_environment_profile(
4704        &self,
4705        instance_id: AgentInstanceId,
4706        provider: &AgentId,
4707        mode: SessionMode,
4708        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4709    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4710        let Some(environment_profile) = environment_profile else {
4711            return Ok(None);
4712        };
4713        let native_profile_id = {
4714            let profiles = self
4715                .environment_profiles
4716                .read()
4717                .unwrap_or_else(|poisoned| poisoned.into_inner());
4718            let binding = profiles
4719                .get(&environment_profile.profile_id)
4720                .ok_or_else(|| {
4721                    failure(
4722                        NodeFailureCode::UnknownEnvironmentProfile,
4723                        "session environment profile is unavailable on this node",
4724                    )
4725                })?;
4726            if &binding.revision != &environment_profile.profile_revision
4727                || &binding.provider != provider
4728            {
4729                return Err(failure(
4730                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4731                    "session environment profile revision or provider changed",
4732                ));
4733            }
4734            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4735                failure(
4736                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4737                    "session environment profile does not support this mode",
4738                )
4739            })?
4740        };
4741        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4742            failure(
4743                NodeFailureCode::BackendOperationFailed,
4744                "native environment profile controller is unavailable",
4745            )
4746        })?;
4747        control
4748            .select_native_launch_profile(instance_id, native_profile_id)
4749            .map_err(|_| {
4750                failure(
4751                    NodeFailureCode::BackendOperationFailed,
4752                    "native environment profile selection failed",
4753                )
4754            })?;
4755        Ok(Some(NativeEnvironmentSelectionGuard {
4756            control: Some(control),
4757            instance_id,
4758        }))
4759    }
4760
4761    fn install_prepared_launch_overlay(
4762        &self,
4763        instance_id: AgentInstanceId,
4764        overlay: PreparedNativeLaunchOverlay,
4765    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4766        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4767            failure(
4768                NodeFailureCode::BackendOperationFailed,
4769                "native launch profile controller is unavailable",
4770            )
4771        })?;
4772        match overlay {
4773            PreparedNativeLaunchOverlay::Environment(overlay) => {
4774                control
4775                    .install_native_launch_environment_overlay(instance_id, overlay)
4776                    .map_err(|_| failure(
4777                        NodeFailureCode::BackendOperationFailed,
4778                        "session environment overlay installation failed",
4779                    ))?;
4780                Ok(None)
4781            }
4782            PreparedNativeLaunchOverlay::Instance(overlay) => {
4783                control
4784                    .install_native_instance_launch_overlay(instance_id, overlay)
4785                    .map_err(|_| failure(
4786                        NodeFailureCode::BundleBindingMismatch,
4787                        "session bundle launch overlay installation failed",
4788                    ))?;
4789                Ok(Some(NativeInstanceOverlayGuard {
4790                    control: Some(control),
4791                    instance_id,
4792                }))
4793            }
4794        }
4795    }
4796
4797    fn environment_materialization_profile(
4798        &self,
4799        environment_profile: &ResolvedEnvironmentProfileReceipt,
4800    ) -> Option<NodeSessionMaterializationProfile> {
4801        self.environment_materialization_profiles
4802            .read()
4803            .unwrap_or_else(|poisoned| poisoned.into_inner())
4804            .get(&environment_profile.profile_id)
4805            .cloned()
4806    }
4807
4808    fn materialization_profile(
4809        &self,
4810        provider: &AgentId,
4811        mode: SessionMode,
4812        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4813        bundle: Option<&ResolvedBundleReceipt>,
4814        context: Option<&ResolvedContextPackReceipt>,
4815    ) -> Result<Option<(
4816        NodeSessionMaterializationProfile,
4817        bool,
4818        Option<BundleProviderLayout>,
4819    )>, NodeFailure> {
4820        let environment = environment_profile
4821            .and_then(|receipt| self.environment_materialization_profile(receipt));
4822        let (bundle, bundle_layout) = match bundle {
4823            Some(receipt) => {
4824                let catalog = self.bundle_catalog.read()
4825                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4826                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4827                    NodeFailureCode::UnknownBundle,
4828                    "session bundle is unavailable on this node",
4829                ))?;
4830                if installed.receipt() != *receipt {
4831                    return Err(failure(
4832                        NodeFailureCode::BundleBindingMismatch,
4833                        "session bundle revision or digest changed",
4834                    ));
4835                }
4836                let layout = self.bundle_layout(
4837                    provider,
4838                    mode,
4839                    environment_profile,
4840                    installed,
4841                )?;
4842                (Some(installed.clone()), Some(layout))
4843            }
4844            None => (None, None),
4845        };
4846        let has_environment_overlay = environment.is_some() || context.is_some();
4847        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4848            (None, None, None) => None,
4849            (Some(profile), None, None) => Some(Ok(profile)),
4850            (None, Some(bundle), Some(layout)) => {
4851                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4852            }
4853            (Some(profile), Some(bundle), Some(layout)) => {
4854                Some(profile.with_bundle(bundle, layout))
4855            }
4856            _ => {
4857                return Err(failure(
4858                    NodeFailureCode::BundleBindingMismatch,
4859                    "session bundle provider layout is unavailable",
4860                ));
4861            }
4862        };
4863        let profile = profile
4864            .transpose()
4865            .map_err(|_| failure(
4866                NodeFailureCode::BundleMaterializationFailed,
4867                "session bundle materialization profile is invalid",
4868            ))?;
4869        let profile = match (profile, context) {
4870            (Some(profile), Some(receipt)) => {
4871                let catalog = self.context_catalog
4872                    .read()
4873                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4874                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4875                    NodeFailureCode::UnknownContextPack,
4876                    "session context pack is unavailable on this node",
4877                ))?;
4878                if pack.receipt() != receipt {
4879                    return Err(failure(
4880                        NodeFailureCode::ContextPackMaterializationFailed,
4881                        "session context pack identity changed",
4882                    ));
4883                }
4884                profile.with_context(pack)
4885            }
4886            (None, Some(receipt)) => {
4887                let catalog = self.context_catalog
4888                    .read()
4889                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4890                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4891                    NodeFailureCode::UnknownContextPack,
4892                    "session context pack is unavailable on this node",
4893                ))?;
4894                if pack.receipt() != receipt {
4895                    return Err(failure(
4896                        NodeFailureCode::ContextPackMaterializationFailed,
4897                        "session context pack identity changed",
4898                    ));
4899                }
4900                NodeSessionMaterializationProfile::from_context(pack)
4901            }
4902            (Some(profile), None) => Ok(profile),
4903            (None, None) => return Ok(None),
4904        }
4905        .map_err(|_| failure(
4906            NodeFailureCode::ContextPackMaterializationFailed,
4907            "session context pack materialization profile is invalid",
4908        ))?;
4909        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4910    }
4911
4912    fn prepare_session_materialization<'a>(
4913        &'a self,
4914        address: &SessionAddress,
4915        provider: &AgentId,
4916        mode: SessionMode,
4917        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4918        bundle: Option<&ResolvedBundleReceipt>,
4919        context: Option<&ResolvedContextPackReceipt>,
4920        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4921    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4922        let Some((profile, has_environment_overlay, bundle_layout)) =
4923            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4924        else {
4925            return Ok(None);
4926        };
4927        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4928            failure(
4929                NodeFailureCode::BackendOperationFailed,
4930                "session environment materializer is unavailable",
4931            )
4932        })?;
4933        let materialization_failure_code = if context.is_some() {
4934            NodeFailureCode::ContextPackMaterializationFailed
4935        } else if bundle.is_some() {
4936            NodeFailureCode::BundleMaterializationFailed
4937        } else {
4938            NodeFailureCode::BackendOperationFailed
4939        };
4940        let id = MaterializationId::new(format!(
4941            "mat-{}-{}-{}",
4942            self.incarnation_id,
4943            address.session.instance_id.0,
4944            address.session.generation.0,
4945        ))
4946        .map_err(|_| {
4947            failure(
4948                NodeFailureCode::BackendOperationFailed,
4949                "session environment materialization identity failed",
4950            )
4951        })?;
4952        let owner = MaterializationOwner::Session {
4953            incarnation_id: self.incarnation_id,
4954            instance_id: address.session.instance_id,
4955            generation: address.session.generation,
4956        };
4957        let mut ownership = materializer
4958            .begin(
4959                id.clone(),
4960                environment_profile.cloned(),
4961                bundle.cloned(),
4962                context.cloned(),
4963                owner,
4964                managed_lease_id,
4965                &profile,
4966                unix_time_ms(),
4967            )
4968            .map_err(|_| {
4969                failure(
4970                    materialization_failure_code,
4971                    "session materialization preparation failed",
4972                )
4973            })?;
4974        {
4975            let _transaction = self
4976                .state_transaction
4977                .lock()
4978                .unwrap_or_else(|poisoned| poisoned.into_inner());
4979            let duplicate = self
4980                .materializations
4981                .lock()
4982                .unwrap_or_else(|poisoned| poisoned.into_inner())
4983                .insert(id.clone(), ownership.clone());
4984            if duplicate.is_some() {
4985                return Err(failure(
4986                    NodeFailureCode::BackendOperationFailed,
4987                    "session environment materialization identity conflict",
4988                ));
4989            }
4990            if let Err(error) = self.persist_state_locked() {
4991                self.materializations
4992                    .lock()
4993                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4994                    .remove(&id);
4995                return Err(persistence_failure(error));
4996            }
4997        }
4998        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4999            Ok(environment) => environment,
5000            Err(_) => {
5001                let transaction = self
5002                    .state_transaction
5003                    .lock()
5004                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5005                self.materializations
5006                    .lock()
5007                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5008                    .insert(id.clone(), ownership.clone());
5009                if self.persist_state_locked().is_err() {
5010                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5011                }
5012                drop(transaction);
5013                if ownership.state() == MaterializationState::CleanupRequired {
5014                    let _ = self.cleanup_materialization(&id);
5015                }
5016                return Err(failure(
5017                    materialization_failure_code,
5018                    "session materialization failed",
5019                ));
5020            }
5021        };
5022        {
5023            let _transaction = self
5024                .state_transaction
5025                .lock()
5026                .unwrap_or_else(|poisoned| poisoned.into_inner());
5027            self.materializations
5028                .lock()
5029                .unwrap_or_else(|poisoned| poisoned.into_inner())
5030                .insert(id.clone(), ownership.clone());
5031            if let Err(error) = self.persist_state_locked() {
5032                drop(_transaction);
5033                let _ = self.cleanup_materialization(&id);
5034                return Err(persistence_failure(error));
5035            }
5036        }
5037        let guard = SessionMaterializationGuard {
5038            shared: self,
5039            id: Some(id),
5040            provider_home: Some(ownership.provider_home().to_path_buf()),
5041        };
5042        let bundle_arguments = match bundle {
5043            Some(receipt) => {
5044                let catalog = self.bundle_catalog.read()
5045                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5046                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5047                    NodeFailureCode::UnknownBundle,
5048                    "session bundle is unavailable on this node",
5049                ))?;
5050                if installed.receipt() != *receipt {
5051                    return Err(failure(
5052                        NodeFailureCode::BundleBindingMismatch,
5053                        "session bundle revision or digest changed",
5054                    ));
5055                }
5056                let layout = bundle_layout.ok_or_else(|| failure(
5057                    NodeFailureCode::BundleBindingMismatch,
5058                    "materialized bundle provider layout is unavailable",
5059                ))?;
5060                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5061                    failure(
5062                        NodeFailureCode::BundleMaterializationFailed,
5063                        "session bundle byte revalidation failed",
5064                    )
5065                })?;
5066                Some(bundle_launch_arguments(
5067                    layout,
5068                    ownership.bundle_root(),
5069                    ownership.provider_home(),
5070                ).map_err(|_| failure(
5071                    NodeFailureCode::BundleBindingMismatch,
5072                    "session bundle launch binding is invalid",
5073                ))?)
5074            }
5075            None => None,
5076        };
5077        if let Some(receipt) = context {
5078            materializer
5079                .revalidate_context(&ownership, receipt)
5080                .map_err(|_| failure(
5081                    NodeFailureCode::ContextPackMaterializationFailed,
5082                    "session context pack byte revalidation failed",
5083                ))?;
5084        }
5085        let transport = match mode {
5086            SessionMode::Pty => TransportKind::Pty,
5087            SessionMode::Inline => TransportKind::Pipe,
5088            SessionMode::Acp => TransportKind::Acp,
5089        };
5090        let overlay = if let Some(extra_args) = bundle_arguments {
5091            Some(PreparedNativeLaunchOverlay::Instance(
5092                NativeInstanceLaunchOverlay::new(
5093                    provider.clone(),
5094                    transport,
5095                    if has_environment_overlay {
5096                        environment
5097                    } else {
5098                        Vec::new()
5099                    },
5100                    extra_args,
5101                )
5102                .map_err(|_| {
5103                    failure(
5104                        NodeFailureCode::BundleBindingMismatch,
5105                        "session bundle launch overlay is invalid",
5106                    )
5107                })?,
5108            ))
5109        } else if has_environment_overlay {
5110            let context_only = environment_profile.is_none()
5111                && bundle.is_none()
5112                && context.is_some();
5113            let overlay = if context_only {
5114                NativeLaunchEnvironmentOverlay::new_context_root(
5115                    provider.clone(),
5116                    transport,
5117                    environment,
5118                )
5119                .map_err(|_| {
5120                    failure(
5121                        NodeFailureCode::ContextPackMaterializationFailed,
5122                        "context-only launch overlay is invalid",
5123                    )
5124                })?
5125            } else {
5126                NativeLaunchEnvironmentOverlay::new(
5127                    provider.clone(),
5128                    transport,
5129                    environment,
5130                )
5131                .map_err(|_| {
5132                    failure(
5133                        NodeFailureCode::BackendOperationFailed,
5134                        "session environment overlay is invalid",
5135                    )
5136                })?
5137            };
5138            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5139        } else {
5140            None
5141        };
5142        Ok(Some((overlay, guard)))
5143    }
5144
5145    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5146        let Some(mut ownership) = self
5147            .materializations
5148            .lock()
5149            .unwrap_or_else(|poisoned| poisoned.into_inner())
5150            .get(id)
5151            .cloned()
5152        else {
5153            return Ok(());
5154        };
5155        if ownership.state() != MaterializationState::CleanupRequired {
5156            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5157                failure(
5158                    NodeFailureCode::BackendOperationFailed,
5159                    "session environment cleanup state is invalid",
5160                )
5161            })?;
5162        }
5163        {
5164            let _transaction = self
5165                .state_transaction
5166                .lock()
5167                .unwrap_or_else(|poisoned| poisoned.into_inner());
5168            let previous = self
5169                .materializations
5170                .lock()
5171                .unwrap_or_else(|poisoned| poisoned.into_inner())
5172                .insert(id.clone(), ownership.clone());
5173            if let Err(error) = self.persist_state_locked() {
5174                let mut materializations = self
5175                    .materializations
5176                    .lock()
5177                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5178                if let Some(previous) = previous {
5179                    materializations.insert(id.clone(), previous);
5180                }
5181                return Err(persistence_failure(error));
5182            }
5183        }
5184        let cleanup = self
5185            .session_environment_materializer
5186            .as_ref()
5187            .ok_or(())
5188            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5189        if cleanup.is_err() {
5190            let _ = ownership.mark_recovery_required(unix_time_ms());
5191            self.materializations
5192                .lock()
5193                .unwrap_or_else(|poisoned| poisoned.into_inner())
5194                .insert(id.clone(), ownership);
5195            if self.persist_state().is_err() {
5196                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5197            }
5198            return Err(failure(
5199                NodeFailureCode::BackendOperationFailed,
5200                "session environment cleanup requires recovery",
5201            ));
5202        }
5203        let _transaction = self
5204            .state_transaction
5205            .lock()
5206            .unwrap_or_else(|poisoned| poisoned.into_inner());
5207        self.materializations
5208            .lock()
5209            .unwrap_or_else(|poisoned| poisoned.into_inner())
5210            .remove(id);
5211        if let Err(error) = self.persist_state_locked() {
5212            self.materializations
5213                .lock()
5214                .unwrap_or_else(|poisoned| poisoned.into_inner())
5215                .insert(id.clone(), ownership);
5216            return Err(persistence_failure(error));
5217        }
5218        Ok(())
5219    }
5220
5221    fn resolve_record_materialization(
5222        &self,
5223        record_id: &SessionRecordId,
5224        provider: &AgentId,
5225        mode: SessionMode,
5226        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5227        bundle: Option<&ResolvedBundleReceipt>,
5228        context: Option<&ResolvedContextPackReceipt>,
5229    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5230        let ownership = {
5231            let materializations = self
5232                .materializations
5233                .lock()
5234                .unwrap_or_else(|poisoned| poisoned.into_inner());
5235            let mut matches = materializations.values().filter(|record| {
5236                record.owner()
5237                    == &MaterializationOwner::Record {
5238                        record_id: record_id.clone(),
5239                    }
5240            });
5241            let ownership = matches.next().cloned();
5242            if matches.next().is_some() {
5243                return Err(failure(
5244                    NodeFailureCode::BackendOperationFailed,
5245                    "managed session has conflicting environment ownership",
5246                ));
5247            }
5248            ownership
5249        };
5250        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5251            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5252                failure(
5253                    NodeFailureCode::ContextPackMaterializationFailed,
5254                    "session context materializer is unavailable",
5255                )
5256            })?;
5257            let pack = materializer
5258                .revalidate_context(ownership, receipt)
5259                .map_err(|_| {
5260                    self.mark_materialization_recovery_required(ownership.id());
5261                    failure(
5262                        NodeFailureCode::ContextPackMaterializationFailed,
5263                        "managed session context pack revalidation failed",
5264                    )
5265                })?;
5266            self.context_catalog
5267                .write()
5268                .unwrap_or_else(|poisoned| poisoned.into_inner())
5269                .insert(pack)
5270                .map_err(|_| failure(
5271                    NodeFailureCode::ContextPackMaterializationFailed,
5272                    "managed session context pack catalog restore failed",
5273                ))?;
5274        }
5275        let materialization = self.materialization_profile(
5276            provider,
5277            mode,
5278            environment_profile,
5279            bundle,
5280            context,
5281        )?;
5282        match (ownership, materialization) {
5283            (None, None) => Ok(None),
5284            (None, Some(_)) | (Some(_), None) => Err(failure(
5285                NodeFailureCode::BackendOperationFailed,
5286                "managed session materialization is unavailable",
5287            )),
5288            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5289                if ownership.environment_profile() != environment_profile
5290                    || ownership.bundle() != bundle
5291                    || ownership.context() != context
5292                    || ownership.state() != MaterializationState::Ready
5293                {
5294                    self.mark_materialization_recovery_required(ownership.id());
5295                    return Err(failure(
5296                        NodeFailureCode::BackendOperationFailed,
5297                        "managed session environment ownership requires recovery",
5298                    ));
5299                }
5300                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5301                    failure(
5302                        NodeFailureCode::BackendOperationFailed,
5303                        "session environment materializer is unavailable",
5304                    )
5305                })?;
5306                if materializer.revalidate(&ownership).is_err() {
5307                    self.mark_materialization_recovery_required(ownership.id());
5308                    return Err(failure(
5309                        NodeFailureCode::BackendOperationFailed,
5310                        "managed session environment revalidation failed",
5311                    ));
5312                }
5313                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5314                    let catalog = self.bundle_catalog.read()
5315                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5316                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5317                        return Err(failure(
5318                            NodeFailureCode::UnknownBundle,
5319                            "managed session bundle is unavailable",
5320                        ));
5321                    };
5322                    let layout = bundle_layout.ok_or_else(|| failure(
5323                        NodeFailureCode::BundleBindingMismatch,
5324                        "record bundle provider layout is unavailable",
5325                    ))?;
5326                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5327                        drop(catalog);
5328                        self.mark_materialization_recovery_required(ownership.id());
5329                        return Err(failure(
5330                            NodeFailureCode::BundleMaterializationFailed,
5331                            "managed session bundle revalidation failed",
5332                        ));
5333                    }
5334                    Some(bundle_launch_arguments(
5335                        layout,
5336                        ownership.bundle_root(),
5337                        ownership.provider_home(),
5338                    ).map_err(|_| failure(
5339                        NodeFailureCode::BundleBindingMismatch,
5340                        "managed session bundle launch binding is invalid",
5341                    ))?)
5342                } else {
5343                    None
5344                };
5345                if !has_environment_materialization {
5346                    let overlay = bundle_arguments.map(|extra_args| {
5347                        NativeInstanceLaunchOverlay::new(
5348                            provider.clone(),
5349                            TransportKind::Pty,
5350                            Vec::new(),
5351                            extra_args,
5352                        )
5353                        .map(PreparedNativeLaunchOverlay::Instance)
5354                        .map_err(|_| failure(
5355                            NodeFailureCode::BundleBindingMismatch,
5356                            "managed session bundle launch overlay is invalid",
5357                        ))
5358                    }).transpose()?;
5359                    return Ok(Some((ownership.id().clone(), overlay)));
5360                }
5361                let environment = match materializer.resolve_environment(&ownership, &profile) {
5362                    Ok(environment) => environment,
5363                    Err(
5364                        SessionEnvironmentMaterializeError::SecretUnavailable
5365                        | SessionEnvironmentMaterializeError::SecretDenied
5366                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5367                    ) => {
5368                        return Err(failure(
5369                            NodeFailureCode::BackendOperationFailed,
5370                            "managed session environment resolution failed",
5371                        ));
5372                    }
5373                    Err(
5374                        SessionEnvironmentMaterializeError::InvalidRoot
5375                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5376                        | SessionEnvironmentMaterializeError::Filesystem(_)
5377                        | SessionEnvironmentMaterializeError::Record(_),
5378                    ) => {
5379                        self.mark_materialization_recovery_required(ownership.id());
5380                        return Err(failure(
5381                            NodeFailureCode::BackendOperationFailed,
5382                            "managed session environment resolution failed",
5383                        ));
5384                    }
5385                };
5386                let transport = match mode {
5387                    SessionMode::Pty => TransportKind::Pty,
5388                    SessionMode::Inline => TransportKind::Pipe,
5389                    SessionMode::Acp => TransportKind::Acp,
5390                };
5391                let overlay = match bundle_arguments {
5392                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5393                        NativeInstanceLaunchOverlay::new(
5394                            provider.clone(),
5395                            transport,
5396                            environment,
5397                            extra_args,
5398                        )
5399                        .map_err(|_| failure(
5400                            NodeFailureCode::BundleBindingMismatch,
5401                            "managed session bundle launch overlay is invalid",
5402                        ))?,
5403                    ),
5404                    None => PreparedNativeLaunchOverlay::Environment(
5405                        NativeLaunchEnvironmentOverlay::new(
5406                            provider.clone(),
5407                            transport,
5408                            environment,
5409                        )
5410                        .map_err(|_| {
5411                            failure(
5412                                NodeFailureCode::BackendOperationFailed,
5413                                "managed session environment overlay is invalid",
5414                            )
5415                        })?,
5416                    ),
5417                };
5418                Ok(Some((ownership.id().clone(), Some(overlay))))
5419            }
5420        }
5421    }
5422
5423    fn destroy_record_materialization(
5424        &self,
5425        record_id: &SessionRecordId,
5426    ) -> Result<bool, NodeFailure> {
5427        let ids = self
5428            .materializations
5429            .lock()
5430            .unwrap_or_else(|poisoned| poisoned.into_inner())
5431            .values()
5432            .filter_map(|record| {
5433                (record.owner()
5434                    == &MaterializationOwner::Record {
5435                        record_id: record_id.clone(),
5436                    })
5437                .then(|| record.id().clone())
5438            })
5439            .collect::<Vec<_>>();
5440        if ids.len() > 1 {
5441            return Err(failure(
5442                NodeFailureCode::BackendOperationFailed,
5443                "managed session has conflicting environment ownership",
5444            ));
5445        }
5446        let Some(id) = ids.first().cloned() else {
5447            return Ok(false);
5448        };
5449        let (terminal_record, mut cleanup_ownership) = {
5450            let _transaction = self
5451                .state_transaction
5452                .lock()
5453                .unwrap_or_else(|poisoned| poisoned.into_inner());
5454            let original_record = self.record(record_id)?;
5455            let mut terminal_record = original_record.clone();
5456            terminal_record.provider_session = None;
5457            terminal_record.active_session = None;
5458            terminal_record.state = ManagedSessionState::Unavailable;
5459            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5460            terminal_record.updated_at_unix_ms = unix_time_ms();
5461            let original_ownership = self
5462                .materializations
5463                .lock()
5464                .unwrap_or_else(|poisoned| poisoned.into_inner())
5465                .get(&id)
5466                .cloned()
5467                .ok_or_else(|| {
5468                    failure(
5469                        NodeFailureCode::BackendOperationFailed,
5470                        "managed session environment ownership disappeared during cleanup",
5471                    )
5472                })?;
5473            let mut cleanup_ownership = original_ownership.clone();
5474            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5475                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5476                    failure(
5477                        NodeFailureCode::BackendOperationFailed,
5478                        "managed session environment cleanup state is invalid",
5479                    )
5480                })?;
5481            }
5482            self.session_records
5483                .lock()
5484                .unwrap_or_else(|poisoned| poisoned.into_inner())
5485                .records
5486                .insert(record_id.clone(), terminal_record.clone());
5487            self.materializations
5488                .lock()
5489                .unwrap_or_else(|poisoned| poisoned.into_inner())
5490                .insert(id.clone(), cleanup_ownership.clone());
5491            if let Err(error) = self.persist_state_locked() {
5492                self.session_records
5493                    .lock()
5494                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5495                    .records
5496                    .insert(record_id.clone(), original_record);
5497                self.materializations
5498                    .lock()
5499                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5500                    .insert(id.clone(), original_ownership);
5501                return Err(persistence_failure(error));
5502            }
5503            (terminal_record, cleanup_ownership)
5504        };
5505        let cleanup = self
5506            .session_environment_materializer
5507            .as_ref()
5508            .ok_or(())
5509            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5510        if cleanup.is_err() {
5511            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5512            self.materializations
5513                .lock()
5514                .unwrap_or_else(|poisoned| poisoned.into_inner())
5515                .insert(id.clone(), cleanup_ownership);
5516            if self.persist_state().is_err() {
5517                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5518            }
5519            return Err(failure(
5520                NodeFailureCode::BackendOperationFailed,
5521                "managed session environment cleanup requires recovery",
5522            ));
5523        }
5524        let _transaction = self
5525            .state_transaction
5526            .lock()
5527            .unwrap_or_else(|poisoned| poisoned.into_inner());
5528        self.remove_record_memory(record_id);
5529        self.materializations
5530            .lock()
5531            .unwrap_or_else(|poisoned| poisoned.into_inner())
5532            .remove(&id);
5533        if let Err(error) = self.persist_state_locked() {
5534            self.session_records
5535                .lock()
5536                .unwrap_or_else(|poisoned| poisoned.into_inner())
5537                .records
5538                .insert(record_id.clone(), terminal_record);
5539            self.materializations
5540                .lock()
5541                .unwrap_or_else(|poisoned| poisoned.into_inner())
5542                .insert(id, cleanup_ownership);
5543            return Err(persistence_failure(error));
5544        }
5545        Ok(true)
5546    }
5547
5548    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5549        let changed = {
5550            let mut materializations = self
5551                .materializations
5552                .lock()
5553                .unwrap_or_else(|poisoned| poisoned.into_inner());
5554            let Some(ownership) = materializations.get_mut(id) else {
5555                return;
5556            };
5557            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5558        };
5559        if changed && self.persist_state().is_err() {
5560            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5561        }
5562    }
5563
5564    fn reconcile_materializations(&self) {
5565        let records = self
5566            .materializations
5567            .lock()
5568            .unwrap_or_else(|poisoned| poisoned.into_inner())
5569            .values()
5570            .cloned()
5571            .collect::<Vec<_>>();
5572        for mut ownership in records {
5573            let result = match (ownership.state(), ownership.owner()) {
5574                (MaterializationState::Preparing, _)
5575                | (MaterializationState::CleanupRequired, _) => {
5576                    self.cleanup_materialization(ownership.id())
5577                }
5578                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5579                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5580                        self.materializations
5581                            .lock()
5582                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5583                            .insert(ownership.id().clone(), ownership.clone());
5584                        self.persist_state().map_err(persistence_failure)
5585                    } else {
5586                        Err(failure(
5587                            NodeFailureCode::BackendOperationFailed,
5588                            "session environment reconciliation failed",
5589                        ))
5590                    }
5591                }
5592                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5593                    .session_environment_materializer
5594                    .as_ref()
5595                    .ok_or_else(|| {
5596                        failure(
5597                            NodeFailureCode::BackendOperationFailed,
5598                            "session environment materializer is unavailable",
5599                        )
5600                    })
5601                    .and_then(|materializer| {
5602                        materializer.revalidate(&ownership).map_err(|_| failure(
5603                            NodeFailureCode::BackendOperationFailed,
5604                            "session materialization revalidation failed",
5605                        ))?;
5606                        let record = self.record(record_id)?;
5607                        if record.environment_profile.as_ref() != ownership.environment_profile()
5608                            || record.bundle.as_ref() != ownership.bundle()
5609                            || record.context.as_ref() != ownership.context()
5610                        {
5611                            return Err(failure(
5612                                NodeFailureCode::BackendOperationFailed,
5613                                "session materialization ownership changed after restart",
5614                            ));
5615                        }
5616                        if let Some(receipt) = ownership.bundle() {
5617                            let catalog = self.bundle_catalog.read()
5618                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5619                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5620                                NodeFailureCode::UnknownBundle,
5621                                "session bundle is unavailable after restart",
5622                            ))?;
5623                            if bundle.receipt() != *receipt {
5624                                return Err(failure(
5625                                    NodeFailureCode::BundleBindingMismatch,
5626                                    "session bundle changed after restart",
5627                                ));
5628                            }
5629                            let layout = self.bundle_layout(
5630                                &record.provider,
5631                                record.mode,
5632                                record.environment_profile.as_ref(),
5633                                bundle,
5634                            )?;
5635                            materializer
5636                                .revalidate_bundle(&ownership, bundle, layout)
5637                                .map_err(|_| failure(
5638                                    NodeFailureCode::BundleMaterializationFailed,
5639                                    "session bundle revalidation failed",
5640                                ))?;
5641                        }
5642                        if let Some(receipt) = ownership.context() {
5643                            let pack = materializer
5644                                .revalidate_context(&ownership, receipt)
5645                                .map_err(|_| {
5646                                    failure(
5647                                        NodeFailureCode::ContextPackMaterializationFailed,
5648                                        "session context revalidation failed",
5649                                    )
5650                                })?;
5651                            self.context_catalog
5652                                .write()
5653                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5654                                .insert(pack)
5655                                .map_err(|_| {
5656                                    failure(
5657                                        NodeFailureCode::ContextPackMaterializationFailed,
5658                                        "session context catalog restore failed",
5659                                    )
5660                                })?;
5661                        }
5662                        Ok(())
5663                    }),
5664                (MaterializationState::RecoveryRequired, _) => Ok(()),
5665            };
5666            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5667                let _ = ownership.mark_recovery_required(unix_time_ms());
5668                self.materializations
5669                    .lock()
5670                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5671                    .insert(ownership.id().clone(), ownership);
5672                if self.persist_state().is_err() {
5673                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5674                }
5675            }
5676        }
5677        let materialized_profiles = self
5678            .environment_materialization_profiles
5679            .read()
5680            .unwrap_or_else(|poisoned| poisoned.into_inner())
5681            .keys()
5682            .cloned()
5683            .collect::<Vec<_>>();
5684        let ownership = self
5685            .materializations
5686            .lock()
5687            .unwrap_or_else(|poisoned| poisoned.into_inner())
5688            .values()
5689            .cloned()
5690            .collect::<Vec<_>>();
5691        let changed = {
5692            let mut session_records = self
5693                .session_records
5694                .lock()
5695                .unwrap_or_else(|poisoned| poisoned.into_inner());
5696            let mut changed = false;
5697            for record in session_records.records.values_mut() {
5698                let environment_profile = record.environment_profile.as_ref();
5699                if environment_profile.is_none()
5700                    && record.bundle.is_none()
5701                    && record.context.is_none()
5702                {
5703                    continue;
5704                }
5705                if record.bundle.is_none()
5706                    && environment_profile.is_some_and(|profile| {
5707                        !materialized_profiles.contains(&profile.profile_id)
5708                    })
5709                {
5710                    continue;
5711                }
5712                let has_exact_ownership = ownership.iter().any(|candidate| {
5713                    candidate.state() == MaterializationState::Ready
5714                        && candidate.environment_profile() == environment_profile
5715                        && candidate.bundle() == record.bundle.as_ref()
5716                        && candidate.context() == record.context.as_ref()
5717                        && candidate.owner()
5718                            == &MaterializationOwner::Record {
5719                                record_id: record.record_id.clone(),
5720                            }
5721                });
5722                if !has_exact_ownership {
5723                    record.provider_session = None;
5724                    record.active_session = None;
5725                    record.state = ManagedSessionState::Unavailable;
5726                    record.last_error = Some(if record.bundle.is_some() {
5727                        "bundle-unavailable".to_owned()
5728                    } else if record.context.is_some() {
5729                        "context-unavailable".to_owned()
5730                    } else {
5731                        "environment-profile-unavailable".to_owned()
5732                    });
5733                    record.updated_at_unix_ms = unix_time_ms();
5734                    changed = true;
5735                }
5736            }
5737            changed
5738        };
5739        if changed && self.persist_state().is_err() {
5740            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5741        }
5742    }
5743
5744    fn replay_spawn_spec(
5745        &self,
5746        spec: &SpawnSpec,
5747        now: Instant,
5748    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5749        let mut cache = self
5750            .spawn_idempotency
5751            .lock()
5752            .unwrap_or_else(|poisoned| poisoned.into_inner());
5753        cache.entries.retain(|_, entry| entry.expires_at > now);
5754        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5755            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5756                return Err(failure(
5757                    NodeFailureCode::SpawnIdempotencyConflict,
5758                    "spawn idempotency key was reused across spawn kinds",
5759                ));
5760            };
5761            if existing != spec {
5762                return Err(failure(
5763                    NodeFailureCode::SpawnIdempotencyConflict,
5764                    "spawn idempotency key was reused with a different specification",
5765                ));
5766            }
5767            return Ok(Some(result.clone()));
5768        }
5769        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5770            return Err(failure(
5771                NodeFailureCode::SpawnIdempotencyCapacity,
5772                "spawn idempotency cache reached its bounded capacity",
5773            ));
5774        }
5775        Ok(None)
5776    }
5777
5778    fn remember_spawn_spec(
5779        &self,
5780        spec: SpawnSpec,
5781        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5782        accepted_at: Instant,
5783    ) {
5784        self
5785            .spawn_idempotency
5786            .lock()
5787            .unwrap_or_else(|poisoned| poisoned.into_inner())
5788            .entries
5789            .insert(
5790                spec.idempotency_key.clone(),
5791                SpawnIdempotencyEntry {
5792                    value: SpawnIdempotencyValue::Standard { spec, result },
5793                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5794                },
5795            );
5796    }
5797
5798    fn claim_harness_mcp_spawn(
5799        &self,
5800        _reservation_id: &HarnessMcpReservationId,
5801        _activation_digest: &HarnessMcpActivationDigest,
5802        spec: &SpawnSpec,
5803        now: Instant,
5804    ) -> Result<(), NodeFailure> {
5805        let mut cache = self.spawn_idempotency.lock()
5806            .unwrap_or_else(|poisoned| poisoned.into_inner());
5807        cache.entries.retain(|_, entry| entry.expires_at > now);
5808        if cache.entries.contains_key(&spec.idempotency_key) {
5809            return Err(failure(
5810                NodeFailureCode::SpawnIdempotencyConflict,
5811                "spawn idempotency key was reused across spawn kinds",
5812            ));
5813        }
5814        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5815            return Err(failure(
5816                NodeFailureCode::SpawnIdempotencyCapacity,
5817                "spawn idempotency cache reached its bounded capacity",
5818            ));
5819        }
5820        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5821            value: SpawnIdempotencyValue::HarnessMcp,
5822            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5823        });
5824        Ok(())
5825    }
5826
5827    fn replay_managed_spawn(
5828        &self,
5829        request: &ManagedWorktreeSpawnRequest,
5830        now: Instant,
5831    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5832        let mut cache = self.spawn_idempotency.lock()
5833            .unwrap_or_else(|poisoned| poisoned.into_inner());
5834        cache.entries.retain(|_, entry| entry.expires_at > now);
5835        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5836            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5837                return Err(failure(
5838                    NodeFailureCode::SpawnIdempotencyConflict,
5839                    "spawn idempotency key was reused across spawn kinds",
5840                ));
5841            };
5842            if existing != request {
5843                return Err(failure(
5844                    NodeFailureCode::SpawnIdempotencyConflict,
5845                    "spawn idempotency key was reused with a different managed specification",
5846                ));
5847            }
5848            return Ok(Some(result.clone()));
5849        }
5850        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5851            return Err(failure(
5852                NodeFailureCode::SpawnIdempotencyCapacity,
5853                "spawn idempotency cache reached its bounded capacity",
5854            ));
5855        }
5856        Ok(None)
5857    }
5858
5859    fn remember_managed_spawn(
5860        &self,
5861        request: ManagedWorktreeSpawnRequest,
5862        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5863        accepted_at: Instant,
5864    ) {
5865        self.spawn_idempotency.lock()
5866            .unwrap_or_else(|poisoned| poisoned.into_inner())
5867            .entries
5868            .insert(
5869                request.spawn_spec.idempotency_key.clone(),
5870                SpawnIdempotencyEntry {
5871                    value: SpawnIdempotencyValue::Managed { request, result },
5872                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5873                },
5874            );
5875    }
5876
5877    fn remember_managed_spawn_attempt(
5878        &self,
5879        durable_v2_key: Option<&SpawnIdempotencyKey>,
5880        request: ManagedWorktreeSpawnRequest,
5881        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5882        accepted_at: Instant,
5883    ) {
5884        if durable_v2_key.is_none() {
5885            self.remember_managed_spawn(request, result, accepted_at);
5886        }
5887    }
5888
5889    fn acquire_durable_managed_spawn_v2(
5890        &self,
5891        request: &ManagedWorktreeSpawnRequestV2,
5892        request_digest: String,
5893    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5894        if self.state_path.is_none() {
5895            return Err(failure(
5896                NodeFailureCode::BackendOperationFailed,
5897                "managed worktree V2 requires durable node state",
5898            ));
5899        }
5900        let _transaction = self.state_transaction
5901            .lock()
5902            .unwrap_or_else(|poisoned| poisoned.into_inner());
5903        let key = request.spawn_spec.idempotency_key.clone();
5904        let mut previous = None;
5905        {
5906            let mut replays = self.managed_spawn_replays
5907                .lock()
5908                .unwrap_or_else(|poisoned| poisoned.into_inner());
5909            if let Some(record) = replays.get_mut(&key) {
5910                if record.request_digest != request_digest {
5911                    return Err(failure(
5912                        NodeFailureCode::SpawnIdempotencyConflict,
5913                        "managed worktree V2 idempotency key was reused with a different request",
5914                    ));
5915                }
5916                match &record.state {
5917                    ManagedWorktreeSpawnReplayStateV10::Pending {
5918                        owner_incarnation_id,
5919                        lease_id: None,
5920                    } if owner_incarnation_id != &self.incarnation_id => {
5921                        previous = Some(record.clone());
5922                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5923                            owner_incarnation_id: self.incarnation_id,
5924                            lease_id: None,
5925                        };
5926                        record.updated_at_unix_ms = unix_time_ms();
5927                    }
5928                    ManagedWorktreeSpawnReplayStateV10::Pending {
5929                        lease_id: None,
5930                        ..
5931                    } => {
5932                        return Err(failure(
5933                            NodeFailureCode::BackendBusy,
5934                            "managed worktree V2 reservation is already active",
5935                        ));
5936                    }
5937                    ManagedWorktreeSpawnReplayStateV10::Pending {
5938                        lease_id: Some(_),
5939                        ..
5940                    } => {
5941                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5942                    }
5943                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5944                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5945                            receipt.clone(),
5946                        ));
5947                    }
5948                }
5949            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5950                return Err(failure(
5951                    NodeFailureCode::SpawnIdempotencyCapacity,
5952                    "durable managed worktree replay ledger reached its bounded capacity",
5953                ));
5954            } else {
5955                let now = unix_time_ms();
5956                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5957                    idempotency_key: key.clone(),
5958                    request_digest,
5959                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5960                    profile_id: request.worktree_profile_id.clone(),
5961                    expected_profile_revision: request.expected_profile_revision.clone(),
5962                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5963                        owner_incarnation_id: self.incarnation_id,
5964                        lease_id: None,
5965                    },
5966                    created_at_unix_ms: now,
5967                    updated_at_unix_ms: now,
5968                });
5969            }
5970        }
5971        if let Err(error) = self.persist_state_locked() {
5972            let mut replays = self.managed_spawn_replays
5973                .lock()
5974                .unwrap_or_else(|poisoned| poisoned.into_inner());
5975            match previous {
5976                Some(previous) => {
5977                    replays.insert(key, previous);
5978                }
5979                None => {
5980                    replays.remove(&key);
5981                }
5982            }
5983            return Err(persistence_failure(error));
5984        }
5985        Ok(DurableManagedSpawnReplayDecision::Reserved)
5986    }
5987
5988    fn link_durable_managed_spawn_v2_lease_locked(
5989        &self,
5990        key: &SpawnIdempotencyKey,
5991        lease_id: &ManagedWorktreeLeaseId,
5992    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5993        let mut replays = self.managed_spawn_replays
5994            .lock()
5995            .unwrap_or_else(|poisoned| poisoned.into_inner());
5996        let record = replays.get_mut(key).ok_or_else(|| {
5997            failure(
5998                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5999                "managed worktree V2 reservation disappeared before allocation",
6000            )
6001        })?;
6002        let previous = record.clone();
6003        match &mut record.state {
6004            ManagedWorktreeSpawnReplayStateV10::Pending {
6005                lease_id: current,
6006                ..
6007            }
6008                if current.is_none() =>
6009            {
6010                *current = Some(lease_id.clone());
6011                record.updated_at_unix_ms = unix_time_ms();
6012                Ok(previous)
6013            }
6014            _ => Err(failure(
6015                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6016                "managed worktree V2 reservation is not allocation-ready",
6017            )),
6018        }
6019    }
6020
6021    fn restore_durable_managed_spawn_v2_locked(
6022        &self,
6023        key: &SpawnIdempotencyKey,
6024        previous: ManagedWorktreeSpawnReplayRecordV10,
6025    ) {
6026        self.managed_spawn_replays
6027            .lock()
6028            .unwrap_or_else(|poisoned| poisoned.into_inner())
6029            .insert(key.clone(), previous);
6030    }
6031
6032    fn commit_durable_managed_spawn_v2(
6033        &self,
6034        request: &ManagedWorktreeSpawnRequestV2,
6035        receipt: ManagedWorktreeSpawnReceipt,
6036    ) -> Result<(), NodeFailure> {
6037        let _transaction = self.state_transaction
6038            .lock()
6039            .unwrap_or_else(|poisoned| poisoned.into_inner());
6040        let key = &request.spawn_spec.idempotency_key;
6041        let previous = {
6042            let mut replays = self.managed_spawn_replays
6043                .lock()
6044                .unwrap_or_else(|poisoned| poisoned.into_inner());
6045            let record = replays.get_mut(key).ok_or_else(|| {
6046                failure(
6047                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6048                    "managed worktree V2 reservation disappeared before commit",
6049                )
6050            })?;
6051            let previous = record.clone();
6052            match &record.state {
6053                ManagedWorktreeSpawnReplayStateV10::Pending {
6054                    lease_id: Some(lease_id),
6055                    ..
6056                } if lease_id == &receipt.lease.lease_id => {}
6057                _ => {
6058                    return Err(failure(
6059                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6060                        "managed worktree V2 reservation is not linked to the spawned lease",
6061                    ));
6062                }
6063            }
6064            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6065            record.updated_at_unix_ms = unix_time_ms();
6066            previous
6067        };
6068        if let Err(error) = self.persist_state_locked() {
6069            self.restore_durable_managed_spawn_v2_locked(key, previous);
6070            return Err(persistence_failure(error));
6071        }
6072        Ok(())
6073    }
6074
6075    fn remove_unallocated_durable_managed_spawn_v2(
6076        &self,
6077        request: &ManagedWorktreeSpawnRequestV2,
6078    ) -> Result<(), NodeFailure> {
6079        let _transaction = self.state_transaction
6080            .lock()
6081            .unwrap_or_else(|poisoned| poisoned.into_inner());
6082        let key = &request.spawn_spec.idempotency_key;
6083        let removed = {
6084            let mut replays = self.managed_spawn_replays
6085                .lock()
6086                .unwrap_or_else(|poisoned| poisoned.into_inner());
6087            match replays.get(key).map(|record| &record.state) {
6088                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6089                    lease_id: None,
6090                    ..
6091                }) => {
6092                    replays.remove(key)
6093                }
6094                _ => None,
6095            }
6096        };
6097        let Some(removed) = removed else {
6098            return Ok(());
6099        };
6100        if let Err(error) = self.persist_state_locked() {
6101            self.managed_spawn_replays
6102                .lock()
6103                .unwrap_or_else(|poisoned| poisoned.into_inner())
6104                .insert(key.clone(), removed);
6105            return Err(persistence_failure(error));
6106        }
6107        Ok(())
6108    }
6109
6110    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6111        if spec.target.node_id != self.node_id {
6112            return Err(failure(
6113                NodeFailureCode::SpawnTargetMismatch,
6114                "spawn target does not match this node",
6115            ));
6116        }
6117        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6118            failure(
6119                NodeFailureCode::UnknownSpawnProfile,
6120                "spawn profile is unavailable on this node",
6121            )
6122        })?;
6123        let resolved = spec.resolve(defaults).map_err(|error| match error {
6124            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6125                NodeFailureCode::SpawnProfileRevisionMismatch,
6126                "spawn profile revision does not match the loaded profile",
6127            ),
6128            _ => failure(
6129                NodeFailureCode::InvalidRequest,
6130                "spawn specification could not be resolved",
6131            ),
6132        })?;
6133        if resolved.target.worktree_id.is_some() {
6134            self.require_worktree_service(&resolved.target.workspace_id)?;
6135        }
6136        let environment_profile = self.resolve_environment_profile(&resolved)?;
6137        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6138        self.resolve_context(&resolved)?;
6139        Ok(resolved)
6140    }
6141
6142    async fn resolve_spawn_workspace(
6143        &self,
6144        resolved: &ResolvedSpawnSpec,
6145        deadline: Instant,
6146    ) -> Result<WorkspaceId, NodeFailure> {
6147        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6148            return Ok(resolved.target.workspace_id.clone());
6149        };
6150        if self.managed_worktrees.lock()
6151            .unwrap_or_else(|poisoned| poisoned.into_inner())
6152            .lease_for_workspace(worktree_id)
6153            .is_some()
6154        {
6155            return Err(failure(
6156                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6157                "managed worktree targets may only be spawned through their lease request",
6158            ));
6159        }
6160        self.require_worktree_service(&resolved.target.workspace_id)?;
6161        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6162        let selected_root = self.workspace_root(worktree_id)?;
6163        let remaining = spawn_deadline_remaining(deadline)?;
6164        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6165            .await
6166            .map_err(|_| {
6167                failure(
6168                    NodeFailureCode::SpawnDeadlineExceeded,
6169                    "Git worktree selection exceeded the spawn deadline",
6170                )
6171            })?
6172            .map_err(git_worktree_failure)?;
6173        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6174        Ok(worktree_id.clone())
6175    }
6176
6177    async fn spawn_from_spec(
6178        &self,
6179        spec: SpawnSpec,
6180    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6181        self.spawn_from_spec_with_proxy(spec, None).await
6182    }
6183
6184    async fn spawn_from_spec_with_proxy(
6185        &self,
6186        spec: SpawnSpec,
6187        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6188    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6189        let accepted_at = Instant::now();
6190        if harness_mcp.is_none() {
6191            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6192                return replayed;
6193            }
6194        }
6195        let resolved = self.resolve_spawn_spec(&spec)?;
6196        let environment_profile = self.resolve_environment_profile(&resolved)?;
6197        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6198        let context = self.resolve_context(&resolved)?;
6199        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6200        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6201        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6202        let spawn_workspace_id = match spawn_workspace_id {
6203            Ok(workspace_id) => workspace_id,
6204            Err(error) => {
6205                let result = Err(error);
6206                if harness_mcp.is_none() {
6207                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6208                }
6209                return result;
6210            }
6211        };
6212        if self.managed_worktrees.lock()
6213            .unwrap_or_else(|poisoned| poisoned.into_inner())
6214            .lease_for_workspace(&spawn_workspace_id)
6215            .is_some()
6216        {
6217            let result = Err(failure(
6218                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6219                "managed worktree targets may only be spawned through their lease request",
6220            ));
6221            if harness_mcp.is_none() {
6222                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6223            }
6224            return result;
6225        }
6226        let result = self
6227            .spawn_session_with_deadline(
6228                spawn_workspace_id,
6229                resolved.provider.clone(),
6230                resolved.mode,
6231                resolved.terminal_size,
6232                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6233                environment_profile.clone(),
6234                bundle.clone(),
6235                context.clone(),
6236                None,
6237                None,
6238                SpawnRecordPolicy::Always,
6239                Some(deadline),
6240                &required_capabilities,
6241                harness_mcp,
6242                resolved.approval_level,
6243                resolved.network_allowlist.clone(),
6244                resolved.browser_profile_id.clone(),
6245            )
6246            .await
6247            .map(|(session, _runtime_policy)| {
6248                resolved.receipt_with_materialization(
6249                    self.incarnation_id,
6250                    session,
6251                    environment_profile,
6252                    bundle,
6253                    context,
6254                )
6255            });
6256        if harness_mcp.is_none() {
6257            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6258        }
6259        result
6260    }
6261
6262    async fn spawn_from_spec_with_harness_mcp(
6263        &self,
6264        reservation_id: HarnessMcpReservationId,
6265        activation_digest: HarnessMcpActivationDigest,
6266        spec: SpawnSpec,
6267        deadline_unix_ms: u64,
6268    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6269        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6270            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6271        })?;
6272        if let Some(receipt) = registry
6273            .replay_spawn(&reservation_id, &activation_digest, &spec)
6274            .map_err(harness_mcp_failure)?
6275        {
6276            return Ok(receipt);
6277        }
6278        let resolved = self.resolve_spawn_spec(&spec)?;
6279        let prepared = registry
6280            .prepare_spawn(
6281                &reservation_id,
6282                &activation_digest,
6283                &spec,
6284                deadline_unix_ms,
6285                resolved.provider.clone(),
6286            )
6287            .map_err(harness_mcp_failure)?;
6288        self.claim_harness_mcp_spawn(
6289            &reservation_id,
6290            &activation_digest,
6291            &spec,
6292            Instant::now(),
6293        )?;
6294        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6295        let mut receipt = match spawn {
6296            Ok(receipt) => receipt,
6297            Err(error) => {
6298                let _ = registry.abort(&reservation_id, &activation_digest);
6299                return Err(error);
6300            }
6301        };
6302        let record_id = self
6303            .session_bindings
6304            .lock()
6305            .unwrap_or_else(|poisoned| poisoned.into_inner())
6306            .get(&receipt.session.session.instance_id)
6307            .and_then(|binding| binding.record_id.clone());
6308        let record_id = match record_id {
6309            Some(record_id) => record_id,
6310            None => {
6311                let rollback = self.remove_session(&receipt.session).await;
6312                let _ = registry.abort(&reservation_id, &activation_digest);
6313                if let Err(rollback_error) = rollback {
6314                    return Err(rollback_error);
6315                }
6316                return Err(failure(
6317                    NodeFailureCode::BindingMismatch,
6318                    "harness MCP spawn did not retain its exact managed record",
6319                ));
6320            }
6321        };
6322        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6323            reservation_id: reservation_id.clone(),
6324            activation_digest: activation_digest.clone(),
6325        });
6326        if let Err(error) = registry.mark_spawned(
6327            &prepared,
6328            receipt.session.clone(),
6329            record_id,
6330            receipt.clone(),
6331        ) {
6332            let rollback = self.remove_session(&receipt.session).await;
6333            let _ = registry.abort(&reservation_id, &activation_digest);
6334            if let Err(rollback_error) = rollback {
6335                return Err(rollback_error);
6336            }
6337            return Err(harness_mcp_failure(error));
6338        }
6339        Ok(receipt)
6340    }
6341
6342    async fn spawn_managed_worktree(
6343        &self,
6344        request: ManagedWorktreeSpawnRequest,
6345    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6346        self.spawn_managed_worktree_inner(request, None).await
6347    }
6348
6349    async fn spawn_managed_worktree_inner(
6350        &self,
6351        request: ManagedWorktreeSpawnRequest,
6352        durable_v2_key: Option<&SpawnIdempotencyKey>,
6353    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6354        let accepted_at = Instant::now();
6355        if durable_v2_key.is_none() {
6356            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6357                return replayed;
6358            }
6359        }
6360        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6361        if request.spawn_spec.target.worktree_id.is_some() {
6362            let result = Err(failure(
6363                NodeFailureCode::InvalidRequest,
6364                "managed worktree spawn must not select a caller-provided worktree",
6365            ));
6366            self.remember_managed_spawn_attempt(
6367                durable_v2_key,
6368                request,
6369                result.clone(),
6370                accepted_at,
6371            );
6372            return result;
6373        }
6374        let environment_profile = self.resolve_environment_profile(&resolved)?;
6375        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6376        let context = self.resolve_context(&resolved)?;
6377        let source_workspace_id = resolved.target.workspace_id.clone();
6378        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6379        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6380        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6381        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6382            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6383            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6384            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6385            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6386        };
6387        let runtime_policy = timeout(
6388            spawn_deadline_remaining(deadline)?,
6389            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6390        ).await.map_err(|_| failure(
6391            NodeFailureCode::SpawnDeadlineExceeded,
6392            "provider admission exceeded the managed spawn deadline",
6393        ))??;
6394        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6395            return Err(failure(
6396                NodeFailureCode::UnsupportedSpawnCapability,
6397                "provider runtime does not admit a required spawn capability",
6398            ));
6399        }
6400        self.ensure_binding_capacity()?;
6401        let source_root = self.workspace_root(&source_workspace_id)?;
6402        profile.validate_for_workspace(&source_root)
6403            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6404        let base_timeout_ms = u64::try_from(
6405            spawn_deadline_remaining(deadline)?.as_millis(),
6406        ).unwrap_or(u64::MAX).max(1);
6407        let base_commit = resolve_base_commit_with_timeout(
6408            &source_root,
6409            profile.base(),
6410            base_timeout_ms,
6411        ).await.map_err(|error| {
6412            if Instant::now() >= deadline || error.message.contains("timed out") {
6413                failure(
6414                    NodeFailureCode::SpawnDeadlineExceeded,
6415                    "managed worktree base resolution exceeded the spawn deadline",
6416                )
6417            } else {
6418                managed_git_worktree_failure(error)
6419            }
6420        })?;
6421
6422        let lease = {
6423            let _transaction = self.state_transaction.lock()
6424                .unwrap_or_else(|poisoned| poisoned.into_inner());
6425            let previous = self.managed_worktrees.lock()
6426                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6427            let lease = self.managed_worktrees.lock()
6428                .unwrap_or_else(|poisoned| poisoned.into_inner())
6429                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6430                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6431            let previous_replay = match durable_v2_key
6432                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6433                .transpose()
6434            {
6435                Ok(previous) => previous,
6436                Err(error) => {
6437                    *self.managed_worktrees.lock()
6438                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6439                    return Err(error);
6440                }
6441            };
6442            if let Err(error) = self.persist_state_locked() {
6443                *self.managed_worktrees.lock()
6444                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6445                if let (Some(key), Some(previous_replay)) =
6446                    (durable_v2_key, previous_replay)
6447                {
6448                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6449                }
6450                return Err(persistence_failure(error));
6451            }
6452            lease
6453        };
6454        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6455        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6456            self.managed_worktrees.lock()
6457                .unwrap_or_else(|poisoned| poisoned.into_inner())
6458                .remove_unmutated(&lease.lease_id);
6459            if self.persist_state().is_err() {
6460                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6461            }
6462            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6463            self.remember_managed_spawn_attempt(
6464                durable_v2_key,
6465                request,
6466                result.clone(),
6467                accepted_at,
6468            );
6469            return result;
6470        }
6471        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6472            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6473            Err(error) => {
6474                self.reconcile_failed_allocation(&lease, &source_root).await;
6475                let result = Err(error);
6476                self.remember_managed_spawn_attempt(
6477                    durable_v2_key,
6478                    request,
6479                    result.clone(),
6480                    accepted_at,
6481                );
6482                return result;
6483            }
6484        };
6485        let created = match create_git_worktree_with_timeout(
6486                &source_root,
6487                &lease.target_root,
6488                &lease.branch,
6489                Some(&base_commit),
6490                mutation_timeout_ms,
6491            ).await {
6492            Ok(created) => created,
6493            Err(error) => {
6494                self.reconcile_failed_allocation(&lease, &source_root).await;
6495                let result = Err(if Instant::now() >= deadline
6496                    || error.message.contains("timed out")
6497                    || error.message.contains("deadline")
6498                {
6499                    failure(
6500                        NodeFailureCode::SpawnDeadlineExceeded,
6501                        "managed worktree creation exceeded the spawn deadline",
6502                    )
6503                } else {
6504                    managed_git_worktree_failure(error)
6505                });
6506                self.remember_managed_spawn_attempt(
6507                    durable_v2_key,
6508                    request,
6509                    result.clone(),
6510                    accepted_at,
6511                );
6512                return result;
6513            }
6514        };
6515        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6516            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6517            let result = Err(failure(
6518                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6519                "created worktree identity did not match its durable lease",
6520            ));
6521            self.remember_managed_spawn_attempt(
6522                durable_v2_key,
6523                request,
6524                result.clone(),
6525                accepted_at,
6526            );
6527            return result;
6528        }
6529        {
6530            let mut registry = self.managed_worktrees.lock()
6531                .unwrap_or_else(|poisoned| poisoned.into_inner());
6532            let current = registry.get_mut(&lease.lease_id)
6533                .expect("allocated managed worktree lease remains present");
6534            current.expected_head = Some(created.head.clone());
6535            current.state = ManagedWorktreeLeaseState::Ready;
6536            current.updated_at_unix_ms = unix_time_ms();
6537        }
6538        if let Err(error) = self.persist_state() {
6539            self.mark_managed_recovery_required(
6540                &lease.lease_id,
6541                ManagedWorktreeCleanupFailure::Backend,
6542            );
6543            let result = Err(persistence_failure(error));
6544            self.remember_managed_spawn_attempt(
6545                durable_v2_key,
6546                request,
6547                result.clone(),
6548                accepted_at,
6549            );
6550            return result;
6551        }
6552        let ready = self.managed_worktrees.lock()
6553            .unwrap_or_else(|poisoned| poisoned.into_inner())
6554            .get(&lease.lease_id)
6555            .expect("ready managed worktree lease remains present")
6556            .snapshot();
6557        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6558        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6559            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6560            let result = Err(error);
6561            self.remember_managed_spawn_attempt(
6562                durable_v2_key,
6563                request,
6564                result.clone(),
6565                accepted_at,
6566            );
6567            return result;
6568        }
6569        let spawn = self.spawn_session_with_deadline(
6570            lease.workspace_id.clone(),
6571            resolved.provider.clone(),
6572            resolved.mode,
6573            resolved.terminal_size,
6574            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6575            environment_profile.clone(),
6576            bundle.clone(),
6577            context.clone(),
6578            Some(lease.lease_id.clone()),
6579            Some(runtime_policy),
6580            SpawnRecordPolicy::Always,
6581            Some(deadline),
6582            &required_capabilities,
6583            None,
6584            resolved.approval_level,
6585            resolved.network_allowlist.clone(),
6586            resolved.browser_profile_id.clone(),
6587        ).await;
6588        let (session, effective_runtime_policy) = match spawn {
6589            Ok(spawned) => spawned,
6590            Err(error) => {
6591                if self
6592                    .cleanup_managed_worktree(&lease.lease_id, true)
6593                    .await
6594                    .is_err()
6595                {
6596                    self.mark_managed_recovery_required(
6597                        &lease.lease_id,
6598                        ManagedWorktreeCleanupFailure::Backend,
6599                    );
6600                }
6601                let result = Err(error);
6602                self.remember_managed_spawn_attempt(
6603                    durable_v2_key,
6604                    request,
6605                    result.clone(),
6606                    accepted_at,
6607                );
6608                return result;
6609            }
6610        };
6611        let (record_id, raw_inventory_record_id) = {
6612            let mut bindings = self.session_bindings.lock()
6613                .unwrap_or_else(|poisoned| poisoned.into_inner());
6614            let binding = bindings.get_mut(&session.session.instance_id)
6615                .expect("successful spawn retains its session binding");
6616            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6617            managed_spawn_record_ownership(binding, effective_runtime_policy)
6618        };
6619        let bound = self.managed_worktrees.lock()
6620            .unwrap_or_else(|poisoned| poisoned.into_inner())
6621            .bind_session(
6622                &lease.lease_id,
6623                ManagedWorktreeSessionHolder {
6624                    incarnation_id: self.incarnation_id,
6625                    instance_id: session.session.instance_id,
6626                    generation: session.session.generation,
6627                },
6628                record_id,
6629                unix_time_ms(),
6630            )
6631            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6632        let snapshot = match bound {
6633            Ok(snapshot) => snapshot,
6634            Err(error) => {
6635                if raw_inventory_record_id
6636                    .as_ref()
6637                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6638                {
6639                    self.mark_managed_recovery_required(
6640                        &lease.lease_id,
6641                        ManagedWorktreeCleanupFailure::Backend,
6642                    );
6643                }
6644                let _ = self.remove_session(&session).await;
6645                self.mark_managed_recovery_required(
6646                    &lease.lease_id,
6647                    ManagedWorktreeCleanupFailure::Backend,
6648                );
6649                let result = Err(error);
6650                self.remember_managed_spawn_attempt(
6651                    durable_v2_key,
6652                    request,
6653                    result.clone(),
6654                    accepted_at,
6655                );
6656                return result;
6657            }
6658        };
6659        if let Err(error) = self.persist_state() {
6660            if raw_inventory_record_id
6661                .as_ref()
6662                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6663            {
6664                self.mark_managed_recovery_required(
6665                    &lease.lease_id,
6666                    ManagedWorktreeCleanupFailure::Backend,
6667                );
6668            }
6669            let _ = self.remove_session(&session).await;
6670            if self
6671                .cleanup_managed_worktree(&lease.lease_id, true)
6672                .await
6673                .is_err()
6674            {
6675                self.mark_managed_recovery_required(
6676                    &lease.lease_id,
6677                    ManagedWorktreeCleanupFailure::Backend,
6678                );
6679            }
6680            let result = Err(persistence_failure(error));
6681            self.remember_managed_spawn_attempt(
6682                durable_v2_key,
6683                request,
6684                result.clone(),
6685                accepted_at,
6686            );
6687            return result;
6688        }
6689        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6690        let receipt = managed_spawn_receipt(
6691            &resolved,
6692            self.incarnation_id,
6693            session,
6694            snapshot,
6695            environment_profile,
6696            bundle,
6697            context,
6698        );
6699        let result = Ok(receipt);
6700        self.remember_managed_spawn_attempt(
6701            durable_v2_key,
6702            request,
6703            result.clone(),
6704            accepted_at,
6705        );
6706        result
6707    }
6708
6709    async fn spawn_managed_worktree_v2(
6710        &self,
6711        request: ManagedWorktreeSpawnRequestV2,
6712    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6713        if request.spawn_spec.target.node_id != self.node_id {
6714            return Err(failure(
6715                NodeFailureCode::SpawnTargetMismatch,
6716                "spawn target does not match this node",
6717            ));
6718        }
6719        if request.spawn_spec.target.worktree_id.is_some() {
6720            return Err(failure(
6721                NodeFailureCode::InvalidRequest,
6722                "managed worktree spawn must not select a caller-provided worktree",
6723            ));
6724        }
6725        let request_digest = managed_spawn_request_digest_v2(&request)?;
6726        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6727        match replay {
6728            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6729            DurableManagedSpawnReplayDecision::PendingLinked => {
6730                return Err(failure(
6731                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6732                    "managed worktree V2 reservation has an incomplete durable allocation",
6733                ));
6734            }
6735            DurableManagedSpawnReplayDecision::Reserved => {}
6736        }
6737        let validated = (|| {
6738            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6739            let profile = self.managed_profile(
6740                &resolved.target.workspace_id,
6741                &request.worktree_profile_id,
6742            )?;
6743            if profile.revision() != &request.expected_profile_revision {
6744                return Err(failure(
6745                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6746                    "managed worktree profile revision does not match the loaded profile",
6747                ));
6748            }
6749            Ok(())
6750        })();
6751        match validated {
6752            Ok(()) => {}
6753            Err(error) => {
6754                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6755                return Err(error);
6756            }
6757        }
6758        let legacy = ManagedWorktreeSpawnRequest {
6759            spawn_spec: request.spawn_spec.clone(),
6760            worktree_profile_id: request.worktree_profile_id.clone(),
6761        };
6762        match self
6763            .spawn_managed_worktree_inner(
6764                legacy,
6765                Some(&request.spawn_spec.idempotency_key),
6766            )
6767            .await
6768        {
6769            Ok(receipt) => {
6770                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6771                Ok(receipt)
6772            }
6773            Err(error) => {
6774                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6775                Err(error)
6776            }
6777        }
6778    }
6779
6780    fn managed_profile(
6781        &self,
6782        workspace_id: &WorkspaceId,
6783        profile_id: &WorktreeProfileId,
6784    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6785        if self.worktree_service_modes.get(workspace_id).copied()
6786            != Some(WorktreeServiceMode::Managed)
6787        {
6788            return Err(failure(
6789                NodeFailureCode::UnsupportedSpawnCapability,
6790                "workspace does not enable managed worktrees",
6791            ));
6792        }
6793        self.managed_worktree_profiles.get(workspace_id)
6794            .and_then(|profiles| profiles.get(profile_id))
6795            .cloned()
6796            .ok_or_else(|| failure(
6797                NodeFailureCode::InvalidRequest,
6798                "managed worktree profile is unavailable for this workspace",
6799            ))
6800    }
6801
6802    async fn reconcile_failed_allocation(
6803        &self,
6804        lease: &ManagedWorktreeLeaseRecord,
6805        source_root: &str,
6806    ) {
6807        match list_git_worktrees(source_root).await {
6808            Ok(worktrees) => {
6809                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6810                    None => {
6811                        match std::fs::symlink_metadata(&lease.target_root) {
6812                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6813                                self.managed_worktrees.lock()
6814                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6815                                    .remove_unmutated(&lease.lease_id);
6816                                let _ = self.persist_state();
6817                            }
6818                            _ => self.mark_managed_recovery_required(
6819                                &lease.lease_id,
6820                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6821                            ),
6822                        }
6823                    }
6824                    Some(item) if exact_owned_worktree(
6825                        lease,
6826                        &item.path,
6827                        item.branch.as_deref(),
6828                        &item.head,
6829                    ) => {
6830                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6831                    }
6832                    Some(_) => self.mark_managed_recovery_required(
6833                        &lease.lease_id,
6834                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6835                    ),
6836                }
6837            }
6838            Err(_) => self.mark_managed_recovery_required(
6839                &lease.lease_id,
6840                ManagedWorktreeCleanupFailure::Backend,
6841            ),
6842        }
6843    }
6844
6845    async fn reconcile_managed_worktrees(&self) {
6846        let leases = self.managed_worktrees.lock()
6847            .unwrap_or_else(|poisoned| poisoned.into_inner())
6848            .active_records()
6849            .cloned()
6850            .collect::<Vec<_>>();
6851        for lease in leases {
6852            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6853                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6854                _ => {
6855                    self.mark_managed_recovery_required(
6856                        &lease.lease_id,
6857                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6858                    );
6859                    continue;
6860                }
6861            };
6862            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6863                self.mark_managed_recovery_required(
6864                    &lease.lease_id,
6865                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6866                );
6867                continue;
6868            }
6869            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6870                Ok(root) => root,
6871                Err(_) => {
6872                    self.mark_managed_recovery_required(
6873                        &lease.lease_id,
6874                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6875                    );
6876                    continue;
6877                }
6878            };
6879            let worktrees = match list_git_worktrees(&source_root).await {
6880                Ok(worktrees) => worktrees,
6881                Err(_) => {
6882                    self.mark_managed_recovery_required(
6883                        &lease.lease_id,
6884                        ManagedWorktreeCleanupFailure::Backend,
6885                    );
6886                    continue;
6887                }
6888            };
6889            let listed = worktrees.iter()
6890                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6891            let Some(listed) = listed else {
6892                match std::fs::symlink_metadata(&lease.target_root) {
6893                    Err(error) if error.kind() == io::ErrorKind::NotFound
6894                        && lease.state == ManagedWorktreeLeaseState::Allocating
6895                        && !lease.has_holders() => {
6896                            let removed = self.managed_worktrees.lock()
6897                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6898                                .tombstone(&lease.lease_id, unix_time_ms());
6899                            if self.persist_state().is_ok() && removed.is_some() {
6900                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6901                                    lease_id: lease.lease_id.clone(),
6902                                });
6903                            }
6904                        }
6905                    _ => self.mark_managed_recovery_required(
6906                        &lease.lease_id,
6907                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6908                    ),
6909                }
6910                continue;
6911            };
6912            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6913                &lease,
6914                &listed.path,
6915                listed.branch.as_deref(),
6916                &listed.head,
6917            ) {
6918                self.mark_managed_recovery_required(
6919                    &lease.lease_id,
6920                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6921                );
6922                continue;
6923            }
6924            if !self.workspaces.read()
6925                .unwrap_or_else(|poisoned| poisoned.into_inner())
6926                .contains_key(&lease.workspace_id)
6927            {
6928                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6929                    self.mark_managed_recovery_required(
6930                        &lease.lease_id,
6931                        ManagedWorktreeCleanupFailure::Backend,
6932                    );
6933                    continue;
6934                }
6935            }
6936            let snapshot = {
6937                let mut registry = self.managed_worktrees.lock()
6938                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6939                let current = registry.get_mut(&lease.lease_id)
6940                    .expect("reconciled managed lease remains present");
6941                current.expected_head = Some(listed.head.clone());
6942                current.cleanup_failure = None;
6943                current.state = if current.has_holders() {
6944                    ManagedWorktreeLeaseState::InUse
6945                } else if current.retention == ManagedWorktreeRetention::Retain {
6946                    ManagedWorktreeLeaseState::Retained
6947                } else {
6948                    ManagedWorktreeLeaseState::Ready
6949                };
6950                current.updated_at_unix_ms = unix_time_ms();
6951                current.snapshot()
6952            };
6953            if self.persist_state().is_err() {
6954                self.mark_managed_recovery_required(
6955                    &lease.lease_id,
6956                    ManagedWorktreeCleanupFailure::Backend,
6957                );
6958                continue;
6959            }
6960            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6961            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6962                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6963            }
6964        }
6965    }
6966
6967    /// Crash-recovery startup sweep: durably capture the pack for every
6968    /// `Live` managed record whose most recent session has not yet produced
6969    /// one (recovers a session whose `Exited{0}` event was observed but the
6970    /// Node crashed before the export finished). The reactive fast path for
6971    /// a single just-exited record does not go through this — see
6972    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6973    /// it can capture the exact `SessionAddress` before `publish_control`
6974    /// clears it, rather than re-deriving it from (by then likely already
6975    /// `Dormant`) record state the way a rescan naturally would.
6976    ///
6977    /// Returns as soon as every eligible record's export has been *spawned*,
6978    /// not completed — each one runs on its own detached task (see
6979    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6980    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6981    /// point this is called from `NodeServer::run()`, so nothing could ever
6982    /// drain the history round trip if this awaited it inline here either).
6983    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6984    /// is a silent skip for THIS record — an unusable source or a racing
6985    /// record is not surfaced to any caller.
6986    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6987        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6988            .session_records
6989            .lock()
6990            .unwrap_or_else(|poisoned| poisoned.into_inner())
6991            .records
6992            .values()
6993            .filter(|record| {
6994                record.state == ManagedSessionState::Live
6995                    && record.exported_context.is_none()
6996                    && record.provider_session.is_some()
6997            })
6998            .filter_map(|record| {
6999                record
7000                    .active_session
7001                    .clone()
7002                    .map(|session| (record.record_id.clone(), session))
7003            })
7004            .collect();
7005        for (record_id, session) in candidates {
7006            let shared = Arc::clone(self);
7007            tokio::spawn(async move {
7008                shared
7009                    .reconcile_context_pack_export_for_record(&record_id, session)
7010                    .await;
7011            });
7012        }
7013    }
7014
7015    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7016    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7017    /// round trip this performs (`discover_history`/`load_history`, via
7018    /// `export_context_pack_for_session_record`) can only ever settle once
7019    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7020    /// completion — and `tick()` is exclusively owned by, and only ever
7021    /// called from, that same drive-loop task. Awaiting this inline there
7022    /// would starve `tick()` and this call could never observe its own
7023    /// command complete.
7024    ///
7025    /// Because this now genuinely races the drive loop's own
7026    /// `publish_control` (which downgrades this exact record from `Live` to
7027    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7028    /// triggered this reconcile), the record legitimately may already be
7029    /// `Dormant` by the time this reads it, or may transition mid-flight —
7030    /// `allow_clean_detachment: true` on the downstream export call is what
7031    /// tolerates exactly that transition (and only that transition; see
7032    /// `session_record_export_target_matches`).
7033    async fn reconcile_context_pack_export_for_record(
7034        &self,
7035        record_id: &SessionRecordId,
7036        session: SessionAddress,
7037    ) {
7038        let Ok(record) = self.record(record_id) else {
7039            return;
7040        };
7041        if !matches!(
7042            record.state,
7043            ManagedSessionState::Live | ManagedSessionState::Dormant
7044        ) || record.exported_context.is_some()
7045            || record.provider_session.is_none()
7046        {
7047            return;
7048        }
7049        // Pack bytes are already durably committed inside this call, via
7050        // commit_context_pack_for_session_record's own durable
7051        // write-through (context_pack_store) — no separate step here.
7052        // Unclean exits (non-zero/forced/no loadable history) are already
7053        // rejected by context_pack_source_status_is_usable inside
7054        // materialize_context_pack: this simply returns Err and is skipped.
7055        let Ok(receipt) = self
7056            .export_context_pack_for_session_record(&record.record_id, &session, true)
7057            .await
7058        else {
7059            return;
7060        };
7061        self.set_exported_context(&record.record_id, receipt);
7062    }
7063
7064    /// Records the record's most recent clean-exit pack. No-op (not an
7065    /// error) if the record disappeared, or if it already carries an
7066    /// `exported_context` — first-write-wins, matching the harness-side
7067    /// idempotent reconciliation that consumes this field.
7068    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7069        let transaction = self
7070            .state_transaction
7071            .lock()
7072            .unwrap_or_else(|poisoned| poisoned.into_inner());
7073        let (previous, updated) = {
7074            let mut records = self
7075                .session_records
7076                .lock()
7077                .unwrap_or_else(|poisoned| poisoned.into_inner());
7078            let Some(record) = records.records.get_mut(record_id) else {
7079                return;
7080            };
7081            if record.exported_context.is_some() {
7082                return;
7083            }
7084            let previous = record.clone();
7085            record.exported_context = Some(receipt);
7086            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7087            (previous, record.clone())
7088        };
7089        if self.persist_state_locked().is_err() {
7090            self.session_records
7091                .lock()
7092                .unwrap_or_else(|poisoned| poisoned.into_inner())
7093                .records
7094                .insert(record_id.clone(), previous);
7095            return;
7096        }
7097        drop(transaction);
7098        self.publish_record(updated);
7099    }
7100
7101    /// Resolves the managed record and its exact `SessionAddress` currently
7102    /// bound to `instance_id` at exactly `generation`, used by the reactive
7103    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7104    /// record without a full rescan. Mirrors the lookup already inlined in
7105    /// `publish_control`. Captured synchronously, before `publish_control`
7106    /// for the same event clears the record's own `active_session` — the
7107    /// caller passes the resolved address through to a detached
7108    /// reconciliation task rather than re-deriving it from the record later,
7109    /// after that race has necessarily already happened.
7110    fn managed_record_export_target_for_instance(
7111        &self,
7112        instance_id: AgentInstanceId,
7113        generation: SessionGeneration,
7114    ) -> Option<(SessionRecordId, SessionAddress)> {
7115        let bindings = self
7116            .session_bindings
7117            .lock()
7118            .unwrap_or_else(|poisoned| poisoned.into_inner());
7119        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7120        let record_id = binding.record_id.clone()?;
7121        Some((
7122            record_id,
7123            SessionAddress {
7124                workspace_id: binding.workspace_id.clone(),
7125                session: SessionKey { instance_id, generation },
7126            },
7127        ))
7128    }
7129
7130    fn mark_managed_recovery_required(
7131        &self,
7132        lease_id: &ManagedWorktreeLeaseId,
7133        failure_kind: ManagedWorktreeCleanupFailure,
7134    ) {
7135        let snapshot = {
7136            let mut registry = self.managed_worktrees.lock()
7137                .unwrap_or_else(|poisoned| poisoned.into_inner());
7138            let Some(lease) = registry.get_mut(lease_id) else { return };
7139            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7140            lease.cleanup_failure = Some(failure_kind);
7141            lease.updated_at_unix_ms = unix_time_ms();
7142            lease.snapshot()
7143        };
7144        let _ = self.persist_state();
7145        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7146    }
7147
7148    fn set_persistence_error(&self, error: Option<String>) {
7149        *self
7150            .persistence_error
7151            .write()
7152            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7153            .map(|message| session_registry::sanitized_persistence_summary(&message));
7154    }
7155
7156    fn persist_state(&self) -> io::Result<()> {
7157        let _transaction = self
7158            .state_transaction
7159            .lock()
7160            .unwrap_or_else(|poisoned| poisoned.into_inner());
7161        self.persist_state_locked()
7162    }
7163
7164    fn persist_state_locked(&self) -> io::Result<()> {
7165        let workspaces = self
7166            .workspaces
7167            .read()
7168            .unwrap_or_else(|poisoned| poisoned.into_inner())
7169            .clone();
7170        let records = self
7171            .session_records
7172            .lock()
7173            .unwrap_or_else(|poisoned| poisoned.into_inner())
7174            .records
7175            .values()
7176            .cloned()
7177            .collect::<Vec<_>>();
7178        let mut managed = self.managed_worktrees
7179            .lock()
7180            .unwrap_or_else(|poisoned| poisoned.into_inner());
7181        managed.reattach_record_holders(
7182            &managed_worktree_record_holders(&records),
7183            unix_time_ms(),
7184        );
7185        let materializations = self
7186            .materializations
7187            .lock()
7188            .unwrap_or_else(|poisoned| poisoned.into_inner())
7189            .values()
7190            .cloned()
7191            .collect::<Vec<_>>();
7192        let managed_spawn_replays = self.managed_spawn_replays
7193            .lock()
7194            .unwrap_or_else(|poisoned| poisoned.into_inner())
7195            .values()
7196            .cloned()
7197            .collect::<Vec<_>>();
7198        let result = session_registry::save_v10(
7199            self.state_path.as_deref(),
7200            &self.node_id,
7201            &workspaces,
7202            &records,
7203            &managed.records(),
7204            &managed.tombstones(),
7205            &materializations,
7206            &managed_spawn_replays,
7207        );
7208        match &result {
7209            Ok(warning) => self.set_persistence_error(warning.clone()),
7210            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7211        }
7212        result.map(|_| ())
7213    }
7214
7215    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7216        let _mutation_guard = self.mutation_gate.lock().await;
7217        self.begin_shutdown_locked().await
7218    }
7219
7220    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7221        if self.shutdown.swap(true, Ordering::AcqRel) {
7222            return Ok(());
7223        }
7224        SessionLifecycleHook::on_shutdown(self);
7225        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7226        let mut first_error = None;
7227        for session in &self.handle.snapshot().sessions {
7228            let remaining = deadline.saturating_duration_since(Instant::now());
7229            if remaining.is_zero() {
7230                first_error.get_or_insert_with(|| {
7231                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7232                });
7233                break;
7234            }
7235            if let Err(error) = self
7236                .dispatch_bounded(
7237                    ControlCommand::Stop {
7238                        instance_id: session.instance_id,
7239                        force: true,
7240                    },
7241                    remaining,
7242                )
7243                .await
7244            {
7245                first_error.get_or_insert(error);
7246            }
7247        }
7248        self.shutdown_notify.notify_waiters();
7249        match first_error {
7250            Some(error) => Err(error),
7251            None => Ok(()),
7252        }
7253    }
7254
7255    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7256        let bindings = self
7257            .session_bindings
7258            .lock()
7259            .unwrap_or_else(|poisoned| poisoned.into_inner());
7260        if bindings.len() >= CONTROL_SESSIONS_MAX {
7261            Err(failure(
7262                NodeFailureCode::BackendBusy,
7263                "node session-binding capacity is full; remove an existing session first",
7264            ))
7265        } else {
7266            Ok(())
7267        }
7268    }
7269
7270    /// One-owner workspace/env collision guard: a live session on
7271    /// `workspace_id` with a different `environment_profile` (option
7272    /// equality on the resolved receipt, including Some vs None) blocks
7273    /// another spawn on that same cwd. Same profile may still share the
7274    /// workspace; distinct profiles need distinct workspace ids (managed
7275    /// worktree).
7276    fn ensure_workspace_environment_profile_compatible(
7277        &self,
7278        workspace_id: &WorkspaceId,
7279        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7280    ) -> Result<(), NodeFailure> {
7281        let bindings = self
7282            .session_bindings
7283            .lock()
7284            .unwrap_or_else(|poisoned| poisoned.into_inner());
7285        for binding in bindings.values() {
7286            if &binding.workspace_id != workspace_id {
7287                continue;
7288            }
7289            if binding.environment_profile.as_ref() != environment_profile {
7290                return Err(failure(
7291                    NodeFailureCode::WorkspaceBusy,
7292                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7293                ));
7294            }
7295        }
7296        Ok(())
7297    }
7298
7299    #[cfg(test)]
7300    fn bind_session(
7301        &self,
7302        instance_id: AgentInstanceId,
7303        workspace_id: WorkspaceId,
7304        generation: SessionGeneration,
7305    ) {
7306        self.bind_session_with_policy(
7307            &SessionAddress {
7308                workspace_id,
7309                session: SessionKey { instance_id, generation },
7310            },
7311            ProviderRuntimePolicy::raw_pty(),
7312            None,
7313        );
7314    }
7315
7316    #[cfg(test)]
7317    fn bind_session_with_policy(
7318        &self,
7319        address: &SessionAddress,
7320        runtime_policy: ProviderRuntimePolicy,
7321        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7322    ) {
7323        self.bind_session_with_materialization(
7324            address,
7325            runtime_policy,
7326            environment_profile,
7327            None,
7328            None,
7329            None,
7330        );
7331    }
7332
7333    fn bind_session_with_materialization(
7334        &self,
7335        address: &SessionAddress,
7336        runtime_policy: ProviderRuntimePolicy,
7337        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7338        bundle: Option<ResolvedBundleReceipt>,
7339        context: Option<ResolvedContextPackReceipt>,
7340        materialization_id: Option<MaterializationId>,
7341    ) {
7342        let mut bindings = self
7343            .session_bindings
7344            .lock()
7345            .unwrap_or_else(|poisoned| poisoned.into_inner());
7346        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7347        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7348        bindings.insert(address.session.instance_id, SessionBinding {
7349            workspace_id: address.workspace_id.clone(),
7350            generation: address.session.generation,
7351            runtime_policy,
7352            pending_resume: None,
7353            record_id: None,
7354            managed_worktree_lease_id: None,
7355            environment_profile,
7356            bundle,
7357            context,
7358            materialization_id,
7359        });
7360    }
7361
7362    #[cfg(test)]
7363    fn bind_managed_session(
7364        &self,
7365        address: &SessionAddress,
7366        record_id: SessionRecordId,
7367        runtime_policy: ProviderRuntimePolicy,
7368        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7369    ) {
7370        self.bind_managed_session_with_materialization(
7371            address,
7372            record_id,
7373            runtime_policy,
7374            environment_profile,
7375            None,
7376            None,
7377            None,
7378        );
7379    }
7380
7381    fn bind_managed_session_with_materialization(
7382        &self,
7383        address: &SessionAddress,
7384        record_id: SessionRecordId,
7385        runtime_policy: ProviderRuntimePolicy,
7386        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7387        bundle: Option<ResolvedBundleReceipt>,
7388        context: Option<ResolvedContextPackReceipt>,
7389        materialization_id: Option<MaterializationId>,
7390    ) {
7391        let mut bindings = self
7392            .session_bindings
7393            .lock()
7394            .unwrap_or_else(|poisoned| poisoned.into_inner());
7395        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7396        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7397        bindings.insert(
7398            address.session.instance_id,
7399            SessionBinding {
7400                workspace_id: address.workspace_id.clone(),
7401                generation: address.session.generation,
7402                runtime_policy,
7403                pending_resume: None,
7404                record_id: Some(record_id),
7405                managed_worktree_lease_id: None,
7406                environment_profile,
7407                bundle,
7408                context,
7409                materialization_id,
7410            },
7411        );
7412    }
7413
7414    #[cfg(test)]
7415    fn bind_spawn_session(
7416        &self,
7417        address: &SessionAddress,
7418        provider: AgentId,
7419        mode: SessionMode,
7420        runtime_policy: ProviderRuntimePolicy,
7421        record_policy: SpawnRecordPolicy,
7422        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7423    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7424        self.bind_spawn_session_with_materialization(
7425            address,
7426            provider,
7427            mode,
7428            runtime_policy,
7429            record_policy,
7430            environment_profile,
7431            None,
7432            None,
7433            None,
7434        )
7435    }
7436
7437    fn bind_spawn_session_with_materialization(
7438        &self,
7439        address: &SessionAddress,
7440        provider: AgentId,
7441        mode: SessionMode,
7442        runtime_policy: ProviderRuntimePolicy,
7443        record_policy: SpawnRecordPolicy,
7444        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7445        bundle: Option<ResolvedBundleReceipt>,
7446        context: Option<ResolvedContextPackReceipt>,
7447        materialization_id: Option<MaterializationId>,
7448    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7449        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7450            && !runtime_policy.provider_session_identity
7451        {
7452            self.bind_session_with_materialization(
7453                address,
7454                runtime_policy,
7455                environment_profile,
7456                bundle,
7457                context,
7458                materialization_id,
7459            );
7460            return Ok(None);
7461        }
7462        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7463        let record = self.new_record(
7464            address,
7465            provider,
7466            mode,
7467            active_record_state(runtime_policy.provider_session_identity, false),
7468            None,
7469            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7470            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7471            record_owns_lifecycle.then(|| context.clone()).flatten(),
7472        )?;
7473        let record_id = record.record_id.clone();
7474        let transaction = self
7475            .state_transaction
7476            .lock()
7477            .unwrap_or_else(|poisoned| poisoned.into_inner());
7478        self.insert_record(record.clone())?;
7479        let previous_materialization = if record_owns_lifecycle {
7480            if let Some(materialization_id) = materialization_id.as_ref() {
7481                let mut materializations = self
7482                    .materializations
7483                    .lock()
7484                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7485                let Some(ownership) = materializations.get_mut(materialization_id) else {
7486                    drop(materializations);
7487                    self.remove_record_memory(&record_id);
7488                    return Err(failure(
7489                        NodeFailureCode::BackendOperationFailed,
7490                        "session environment ownership disappeared before binding",
7491                    ));
7492                };
7493                let previous = ownership.clone();
7494                if ownership
7495                    .transfer_to_record(record_id.clone(), unix_time_ms())
7496                    .is_err()
7497                {
7498                    drop(materializations);
7499                    self.remove_record_memory(&record_id);
7500                    return Err(failure(
7501                        NodeFailureCode::BackendOperationFailed,
7502                        "session environment ownership transfer failed",
7503                    ));
7504                }
7505                Some(previous)
7506            } else {
7507                None
7508            }
7509        } else {
7510            None
7511        };
7512        self.bind_managed_session_with_materialization(
7513            address,
7514            record_id.clone(),
7515            runtime_policy,
7516            environment_profile,
7517            bundle,
7518            context,
7519            materialization_id.clone(),
7520        );
7521        if let Err(error) = self.persist_state_locked() {
7522            self.remove_binding(address);
7523            self.remove_record_memory(&record_id);
7524            if let (Some(materialization_id), Some(previous)) =
7525                (materialization_id.as_ref(), previous_materialization)
7526            {
7527                self.materializations
7528                    .lock()
7529                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7530                    .insert(materialization_id.clone(), previous);
7531            }
7532            return Err(persistence_failure(error));
7533        }
7534        drop(transaction);
7535        self.publish_record(record);
7536        Ok(Some(record_id))
7537    }
7538
7539    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7540        for _ in 0..8 {
7541            let bytes = random_nonce().map_err(|error| {
7542                failure(
7543                    NodeFailureCode::BackendOperationFailed,
7544                    &format!("session record identity generation failed: {error}"),
7545                )
7546            })?;
7547            let mut value = String::with_capacity(27);
7548            value.push_str("sr-");
7549            for byte in &bytes[..12] {
7550                use std::fmt::Write as _;
7551                let _ = write!(&mut value, "{byte:02x}");
7552            }
7553            let record_id = SessionRecordId::new(value).map_err(|error| {
7554                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7555            })?;
7556            let records = self
7557                .session_records
7558                .lock()
7559                .unwrap_or_else(|poisoned| poisoned.into_inner());
7560            if !records.records.contains_key(&record_id) {
7561                return Ok(record_id);
7562            }
7563        }
7564        Err(failure(
7565            NodeFailureCode::BackendBusy,
7566            "could not allocate a unique session record ID",
7567        ))
7568    }
7569
7570    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7571        for _ in 0..8 {
7572            let bytes = random_nonce().map_err(|error| {
7573                failure(
7574                    NodeFailureCode::BackendOperationFailed,
7575                    &format!("task identity generation failed: {error}"),
7576                )
7577            })?;
7578            let mut nonce = [0_u8; 12];
7579            nonce.copy_from_slice(&bytes[..12]);
7580            let task_id = TaskId::from_nonce(nonce);
7581            let records = self
7582                .session_records
7583                .lock()
7584                .unwrap_or_else(|poisoned| poisoned.into_inner());
7585            if !records.records.values().any(|record| {
7586                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7587                    == Some(&task_id)
7588            }) {
7589                return Ok(task_id);
7590            }
7591        }
7592        Err(failure(
7593            NodeFailureCode::BackendBusy,
7594            "could not allocate a unique task ID",
7595        ))
7596    }
7597
7598    fn default_record_name(&self, provider: &AgentId) -> String {
7599        let records = self
7600            .session_records
7601            .lock()
7602            .unwrap_or_else(|poisoned| poisoned.into_inner());
7603        let ordinal = records
7604            .records
7605            .values()
7606            .filter(|record| &record.provider == provider)
7607            .count()
7608            .saturating_add(1);
7609        format!("{} #{ordinal}", provider.as_str())
7610    }
7611
7612    fn new_record(
7613        &self,
7614        address: &SessionAddress,
7615        provider: AgentId,
7616        mode: SessionMode,
7617        state: ManagedSessionState,
7618        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7619        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7620        bundle: Option<ResolvedBundleReceipt>,
7621        context: Option<ResolvedContextPackReceipt>,
7622    ) -> Result<ManagedSessionRecord, NodeFailure> {
7623        let canonical_root = self.workspace_root(&address.workspace_id)?;
7624        let now = unix_time_ms();
7625        Ok(ManagedSessionRecord {
7626            record_id: self.allocate_record_id()?,
7627            display_name: self.default_record_name(&provider),
7628            provider,
7629            mode,
7630            state,
7631            workspace_id: address.workspace_id.clone(),
7632            canonical_root: opaque_windows_path(canonical_root),
7633            provider_session,
7634            active_session: Some(address.clone()),
7635            environment_profile,
7636            bundle,
7637            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7638            context,
7639            exported_context: None,
7640            task_binding: None,
7641            created_at_unix_ms: now,
7642            updated_at_unix_ms: now,
7643            last_error: None,
7644        })
7645    }
7646
7647    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7648        let mut records = self
7649            .session_records
7650            .lock()
7651            .unwrap_or_else(|poisoned| poisoned.into_inner());
7652        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7653            return Err(failure(
7654                NodeFailureCode::BackendBusy,
7655                "managed session record capacity is full; forget an inactive record first",
7656            ));
7657        }
7658        if let Some(identity) = record.provider_session.as_ref() {
7659            if records.records.values().any(|candidate| {
7660                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7661                    session_registry::same_provider_session(
7662                        &candidate.provider,
7663                        candidate_identity,
7664                        &record.provider,
7665                        identity,
7666                    )
7667                })
7668            }) {
7669                return Err(failure(
7670                    NodeFailureCode::SessionRecordConflict,
7671                    "provider session is already represented by another managed record",
7672                ));
7673            }
7674        }
7675        match records.records.entry(record.record_id.clone()) {
7676            std::collections::btree_map::Entry::Vacant(entry) => {
7677                entry.insert(record);
7678            }
7679            std::collections::btree_map::Entry::Occupied(_) => {
7680                return Err(failure(
7681                    NodeFailureCode::SessionRecordConflict,
7682                    "session record ID collision",
7683                ));
7684            }
7685        }
7686        Ok(())
7687    }
7688
7689    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7690        self.session_records
7691            .lock()
7692            .unwrap_or_else(|poisoned| poisoned.into_inner())
7693            .records
7694            .get(record_id)
7695            .cloned()
7696            .ok_or_else(|| {
7697                failure(
7698                    NodeFailureCode::UnknownSessionRecord,
7699                    "managed session record does not exist",
7700                )
7701            })
7702    }
7703
7704    fn revalidate_session_record_preview(
7705        &self,
7706        expected: &ManagedSessionRecord,
7707        identity: &ProviderSessionIdentity,
7708    ) -> Result<(), NodeFailure> {
7709        let current = self.record(&expected.record_id)?;
7710        let current_root = self.workspace_root(&current.workspace_id)?;
7711        if current.provider != expected.provider
7712            || current.workspace_id != expected.workspace_id
7713            || current.canonical_root != expected.canonical_root
7714            || current.provider_session.as_ref() != Some(identity)
7715            || !platform::roots_equal(
7716                &current_root,
7717                windows_path_text(&current.canonical_root),
7718            )
7719        {
7720            return Err(failure(
7721                NodeFailureCode::SessionRecordConflict,
7722                "managed session changed while its preview was loading",
7723            ));
7724        }
7725        Ok(())
7726    }
7727
7728    fn revalidate_session_record_context_export(
7729        &self,
7730        expected: &ManagedSessionRecord,
7731        identity: Option<&ProviderSessionIdentity>,
7732        session: &SessionAddress,
7733        allow_clean_detachment: bool,
7734    ) -> Result<(), NodeFailure> {
7735        let current = self.record(&expected.record_id)?;
7736        let current_root = self.workspace_root(&current.workspace_id)?;
7737        let snapshot = self.internal_session_snapshot(session)?;
7738        let binding = self
7739            .session_bindings
7740            .lock()
7741            .unwrap_or_else(|poisoned| poisoned.into_inner())
7742            .get(&session.session.instance_id)
7743            .cloned()
7744            .ok_or_else(|| {
7745                failure(
7746                    NodeFailureCode::SessionRecordConflict,
7747                    "managed session runtime binding disappeared during context export",
7748                )
7749            })?;
7750        if !session_record_context_export_binding_is_exact(
7751            expected,
7752            &current,
7753            identity,
7754            session,
7755            &binding,
7756            &snapshot.agent_id,
7757            &current_root,
7758            allow_clean_detachment,
7759        ) || !session_record_context_export_source_is_usable(
7760            &current,
7761            &snapshot.status,
7762            allow_clean_detachment,
7763        ) {
7764            return Err(failure(
7765                NodeFailureCode::SessionRecordConflict,
7766                "managed session binding changed during context export",
7767            ));
7768        }
7769        Ok(())
7770    }
7771
7772    fn revalidate_loaded_history(
7773        &self,
7774        session: &SessionAddress,
7775        candidate_id: &str,
7776        loaded: &HistorySessionRecord,
7777    ) -> Result<(), NodeFailure> {
7778        let snapshot = self.internal_session_snapshot(session)?;
7779        if snapshot.history.pending.is_some()
7780            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7781            || snapshot.history.loaded.as_ref() != Some(loaded)
7782        {
7783            return Err(failure(
7784                NodeFailureCode::SessionRecordConflict,
7785                "managed session history changed during context export",
7786            ));
7787        }
7788        Ok(())
7789    }
7790
7791    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7792        self.session_records
7793            .lock()
7794            .unwrap_or_else(|poisoned| poisoned.into_inner())
7795            .records
7796            .remove(record_id)
7797    }
7798
7799    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7800        if self.destroy_record_materialization(record_id)? {
7801            self.publish(NodeEvent::SessionRecordRemoved {
7802                record_id: record_id.clone(),
7803            });
7804            return Ok(());
7805        }
7806        let transaction = self
7807            .state_transaction
7808            .lock()
7809            .unwrap_or_else(|poisoned| poisoned.into_inner());
7810        let Some(removed) = self.remove_record_memory(record_id) else {
7811            return Ok(());
7812        };
7813        if let Err(error) = self.persist_state_locked() {
7814            let _ = self.insert_record(removed);
7815            return Err(persistence_failure(error));
7816        }
7817        drop(transaction);
7818        self.publish(NodeEvent::SessionRecordRemoved {
7819            record_id: record_id.clone(),
7820        });
7821        Ok(())
7822    }
7823
7824    fn publish_record(&self, record: ManagedSessionRecord) {
7825        self.publish(NodeEvent::SessionRecordUpserted { record });
7826    }
7827
7828    fn mark_record_error(
7829        &self,
7830        record_id: &SessionRecordId,
7831        message: &str,
7832    ) -> Result<(), NodeFailure> {
7833        let transaction = self
7834            .state_transaction
7835            .lock()
7836            .unwrap_or_else(|poisoned| poisoned.into_inner());
7837        let previous = {
7838            let mut records = self
7839                .session_records
7840                .lock()
7841                .unwrap_or_else(|poisoned| poisoned.into_inner());
7842            let Some(record) = records.records.get_mut(record_id) else {
7843                return Ok(());
7844            };
7845            let previous = record.clone();
7846            record.state = ManagedSessionState::Unavailable;
7847            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7848            record.updated_at_unix_ms = unix_time_ms();
7849            previous
7850        };
7851        if let Err(error) = self.persist_state_locked() {
7852            self.session_records
7853                .lock()
7854                .unwrap_or_else(|poisoned| poisoned.into_inner())
7855                .records
7856                .insert(record_id.clone(), previous);
7857            return Err(persistence_failure(error));
7858        }
7859        let record = self.record(record_id)?;
7860        drop(transaction);
7861        self.publish_record(record);
7862        Ok(())
7863    }
7864
7865    fn rename_session_record(
7866        &self,
7867        record_id: &SessionRecordId,
7868        display_name: String,
7869    ) -> Result<ManagedSessionRecord, NodeFailure> {
7870        validate_display_name(&display_name)
7871            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7872        let _transaction = self
7873            .state_transaction
7874            .lock()
7875            .unwrap_or_else(|poisoned| poisoned.into_inner());
7876        let previous = {
7877            let mut records = self
7878                .session_records
7879                .lock()
7880                .unwrap_or_else(|poisoned| poisoned.into_inner());
7881            let record = records.records.get_mut(record_id).ok_or_else(|| {
7882                failure(
7883                    NodeFailureCode::UnknownSessionRecord,
7884                    "managed session record does not exist",
7885                )
7886            })?;
7887            let previous = record.clone();
7888            record.display_name = display_name;
7889            record.updated_at_unix_ms = unix_time_ms();
7890            previous
7891        };
7892        if let Err(error) = self.persist_state_locked() {
7893            self.session_records
7894                .lock()
7895                .unwrap_or_else(|poisoned| poisoned.into_inner())
7896                .records
7897                .insert(record_id.clone(), previous);
7898            return Err(persistence_failure(error));
7899        }
7900        let updated = self.record(record_id)?;
7901        self.publish_record(updated.clone());
7902        Ok(updated)
7903    }
7904
7905    fn set_session_task(
7906        &self,
7907        record_id: &SessionRecordId,
7908        expected_revision: u64,
7909        target: SessionTaskTargetV1,
7910    ) -> Result<ManagedSessionRecord, NodeFailure> {
7911        let transaction = self
7912            .state_transaction
7913            .lock()
7914            .unwrap_or_else(|poisoned| poisoned.into_inner());
7915        let current = self.record(record_id)?;
7916        let current_task_id = current
7917            .task_binding
7918            .as_ref()
7919            .and_then(|binding| binding.task_id.as_ref());
7920        let current_revision = current
7921            .task_binding
7922            .as_ref()
7923            .map_or(0, |binding| binding.revision);
7924        if expected_revision != current_revision {
7925            return Err(failure(
7926                NodeFailureCode::SessionRecordConflict,
7927                "managed session task binding revision changed",
7928            ));
7929        }
7930        let exact = match &target {
7931            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7932            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7933            SessionTaskTargetV1::New => false,
7934        };
7935        if exact {
7936            return Ok(current);
7937        }
7938        let revision = current_revision.checked_add(1).ok_or_else(|| {
7939            failure(
7940                NodeFailureCode::SessionRecordConflict,
7941                "managed session task binding revision is exhausted",
7942            )
7943        })?;
7944        let task_id = match target {
7945            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7946            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7947            SessionTaskTargetV1::Clear => None,
7948        };
7949        let changed_at_unix_ms = unix_time_ms()
7950            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7951                failure(
7952                    NodeFailureCode::SessionRecordConflict,
7953                    "managed session record timestamp is exhausted",
7954                )
7955            })?);
7956        let updated = {
7957            let mut records = self
7958                .session_records
7959                .lock()
7960                .unwrap_or_else(|poisoned| poisoned.into_inner());
7961            let record = records.records.get_mut(record_id).ok_or_else(|| {
7962                failure(
7963                    NodeFailureCode::UnknownSessionRecord,
7964                    "managed session record does not exist",
7965                )
7966            })?;
7967            record.task_binding = Some(SessionTaskBindingV1 {
7968                revision,
7969                task_id,
7970                changed_at_unix_ms,
7971            });
7972            record.updated_at_unix_ms = changed_at_unix_ms;
7973            record.clone()
7974        };
7975        if let Err(error) = self.persist_state_locked() {
7976            self.session_records
7977                .lock()
7978                .unwrap_or_else(|poisoned| poisoned.into_inner())
7979                .records
7980                .insert(record_id.clone(), current);
7981            return Err(persistence_failure(error));
7982        }
7983        drop(transaction);
7984        self.publish_record(updated.clone());
7985        Ok(updated)
7986    }
7987
7988    fn index_provider_session(
7989        &self,
7990        workspace_id: WorkspaceId,
7991        provider: AgentId,
7992        identity: ProviderSessionIdentity,
7993        display_name: String,
7994    ) -> Result<ManagedSessionRecord, NodeFailure> {
7995        self.index_provider_session_with_policy(
7996            workspace_id,
7997            provider,
7998            identity,
7999            display_name,
8000            false,
8001        )
8002    }
8003
8004    fn index_provider_session_with_policy(
8005        &self,
8006        workspace_id: WorkspaceId,
8007        provider: AgentId,
8008        identity: ProviderSessionIdentity,
8009        display_name: String,
8010        allow_validated_transcript_path: bool,
8011    ) -> Result<ManagedSessionRecord, NodeFailure> {
8012        validate_display_name(&display_name)
8013            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8014        identity
8015            .validate()
8016            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8017        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8018            return Err(failure(
8019                NodeFailureCode::InvalidRequest,
8020                "provider session reference index v1 does not accept transcript paths",
8021            ));
8022        }
8023        let canonical_root = self.workspace_root(&workspace_id)?;
8024        if !self.enabled_providers.contains(&provider) {
8025            return Err(failure(
8026                NodeFailureCode::InvalidRequest,
8027                "provider is not enabled on this node",
8028            ));
8029        }
8030
8031        let transaction = self
8032            .state_transaction
8033            .lock()
8034            .unwrap_or_else(|poisoned| poisoned.into_inner());
8035        {
8036            let records = self
8037                .session_records
8038                .lock()
8039                .unwrap_or_else(|poisoned| poisoned.into_inner());
8040            if let Some(existing) = records.records.values().find(|candidate| {
8041                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8042                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8043                })
8044            }) {
8045                if existing.provider == provider && existing.workspace_id == workspace_id {
8046                    return Ok(existing.clone());
8047                }
8048                return Err(failure(
8049                    NodeFailureCode::SessionRecordConflict,
8050                    "provider session reference conflicts with another provider or workspace",
8051                ));
8052            }
8053            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8054                return Err(failure(
8055                    NodeFailureCode::BackendBusy,
8056                    "managed session record capacity is full; forget an inactive record first",
8057                ));
8058            }
8059        }
8060
8061        let now = unix_time_ms();
8062        let record = ManagedSessionRecord {
8063            record_id: self.allocate_record_id()?,
8064            display_name,
8065            provider,
8066            mode: SessionMode::Pty,
8067            state: ManagedSessionState::Dormant,
8068            workspace_id,
8069            canonical_root: opaque_windows_path(canonical_root),
8070            provider_session: Some(identity),
8071            active_session: None,
8072            environment_profile: None,
8073            bundle: None,
8074            context_id: None,
8075            context: None,
8076            exported_context: None,
8077            task_binding: None,
8078            created_at_unix_ms: now,
8079            updated_at_unix_ms: now,
8080            last_error: None,
8081        };
8082        self.insert_record(record.clone())?;
8083        if let Err(error) = self.persist_state_locked() {
8084            self.remove_record_memory(&record.record_id);
8085            return Err(persistence_failure(error));
8086        }
8087        drop(transaction);
8088        self.publish_record(record.clone());
8089        Ok(record)
8090    }
8091
8092    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8093        self.session_bindings
8094            .lock()
8095            .unwrap_or_else(|poisoned| poisoned.into_inner())
8096            .iter()
8097            .find_map(|(instance_id, binding)| {
8098                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8099                    workspace_id: binding.workspace_id.clone(),
8100                    session: SessionKey {
8101                        instance_id: *instance_id,
8102                        generation: binding.generation,
8103                    },
8104                })
8105            })
8106    }
8107
8108    async fn forget_session_record(
8109        &self,
8110        record_id: &SessionRecordId,
8111    ) -> Result<(), NodeFailure> {
8112        let record = self.record(record_id)?;
8113        if record.active_session.is_some()
8114            || matches!(
8115                record.state,
8116                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8117            )
8118        {
8119            return Err(failure(
8120                NodeFailureCode::SessionRecordBusy,
8121                "stop the managed session before forgetting it",
8122            ));
8123        }
8124        if let Some(address) = self.bound_address_for_record(record_id) {
8125            self.remove_session(&address).await?;
8126        }
8127        self.remove_managed_session_record(record_id, &record.workspace_id).await
8128    }
8129
8130    /// Removes one managed session record from durable state and notifies
8131    /// every listener -- the shared transaction body behind both
8132    /// `forget_session_record`'s operator-driven path (busy-checked above
8133    /// it, and clears any still-live session binding before calling in
8134    /// here) and the retention sweep (`retire_unavailable_session_records`,
8135    /// which only ever selects `ManagedSessionState::Unavailable` records
8136    /// that are already known never-busy and never bound, so it calls
8137    /// straight into this with neither check). Cleans a managed-worktree
8138    /// lease if the record held one, removes the record's environment
8139    /// materialization (or, absent one, removes it from memory directly),
8140    /// persists under `state_transaction` with rollback on persist failure,
8141    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8142    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8143    /// exactly as it does for an operator-driven forget.
8144    async fn remove_managed_session_record(
8145        &self,
8146        record_id: &SessionRecordId,
8147        workspace_id: &WorkspaceId,
8148    ) -> Result<(), NodeFailure> {
8149        let managed_lease_id = self.managed_worktrees.lock()
8150            .unwrap_or_else(|poisoned| poisoned.into_inner())
8151            .lease_for_workspace(workspace_id);
8152        if !self.destroy_record_materialization(record_id)? {
8153            let transaction = self
8154                .state_transaction
8155                .lock()
8156                .unwrap_or_else(|poisoned| poisoned.into_inner());
8157            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8158                failure(
8159                    NodeFailureCode::UnknownSessionRecord,
8160                    "managed session record does not exist",
8161                )
8162            })?;
8163            if let Err(error) = self.persist_state_locked() {
8164                let _ = self.insert_record(removed);
8165                return Err(persistence_failure(error));
8166            }
8167            drop(transaction);
8168        }
8169        self.publish(NodeEvent::SessionRecordRemoved {
8170            record_id: record_id.clone(),
8171        });
8172        if let Some(lease_id) = managed_lease_id {
8173            if let Some(snapshot) = self.managed_worktrees.lock()
8174                .unwrap_or_else(|poisoned| poisoned.into_inner())
8175                .get(&lease_id)
8176                .map(ManagedWorktreeLeaseRecord::snapshot)
8177            {
8178                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8179            }
8180            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8181        }
8182        Ok(())
8183    }
8184
8185    /// Retires `ManagedSessionState::Unavailable` records against the
8186    /// node's configured `session_record_retention` age/keep-N policy.
8187    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8188    /// `Dormant` is resumable and folding it into this sweep would delete
8189    /// records a caller can still act on. Both policy fields default to
8190    /// `0` (disabled); when both are `0` this is a no-op. When both are
8191    /// set, a record is retired only if it fails BOTH tests (older than
8192    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8193    /// workspace); when only one is set, that one alone decides. Called
8194    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8195    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8196    /// loop -- see both call sites.
8197    async fn retire_unavailable_session_records(&self) {
8198        let policy = self.session_record_retention;
8199        let age_enabled = policy.age_ms > 0;
8200        let keep_enabled = policy.keep_per_workspace > 0;
8201        if !age_enabled && !keep_enabled {
8202            return;
8203        }
8204        let now = unix_time_ms();
8205        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8206            self.session_records
8207                .lock()
8208                .unwrap_or_else(|poisoned| poisoned.into_inner())
8209                .records
8210                .values()
8211                .filter(|record| record.state == ManagedSessionState::Unavailable)
8212                .map(|record| {
8213                    (
8214                        record.record_id.clone(),
8215                        record.workspace_id.clone(),
8216                        record.provider.clone(),
8217                        record.updated_at_unix_ms,
8218                    )
8219                })
8220                .collect()
8221        };
8222        if candidates.is_empty() {
8223            return;
8224        }
8225        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8226            BTreeMap::new();
8227        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8228            by_workspace
8229                .entry(workspace_id)
8230                .or_default()
8231                .push((record_id, provider, updated_at_unix_ms));
8232        }
8233        let reason = if age_enabled && keep_enabled {
8234            "age+keep-n"
8235        } else if age_enabled {
8236            "age"
8237        } else {
8238            "keep-n"
8239        };
8240        let mut retired: u64 = 0;
8241        for (workspace_id, mut records) in by_workspace {
8242            // Newest `updated_at_unix_ms` first, so index `>=
8243            // keep_per_workspace` names exactly "beyond the newest N".
8244            records.sort_by(|left, right| right.2.cmp(&left.2));
8245            for (index, (record_id, provider, updated_at_unix_ms)) in
8246                records.into_iter().enumerate()
8247            {
8248                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8249                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8250                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8251                let should_retire = if age_enabled && keep_enabled {
8252                    age_hit && keep_hit
8253                } else {
8254                    age_hit || keep_hit
8255                };
8256                if !should_retire {
8257                    continue;
8258                }
8259                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8260                    Ok(()) => {
8261                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8262                        retired += 1;
8263                        tracing::info!(
8264                            node_id = %self.node_id,
8265                            record_id = %record_id,
8266                            workspace_id = %workspace_id,
8267                            provider = %provider,
8268                            age_ms = record_age_ms,
8269                            reason,
8270                            "managed session record retired"
8271                        );
8272                    }
8273                    Err(error) => tracing::warn!(
8274                        node_id = %self.node_id,
8275                        record_id = %record_id,
8276                        workspace_id = %workspace_id,
8277                        code = ?error.code,
8278                        cause = %error.message,
8279                        "session record retention sweep failed to retire a record"
8280                    ),
8281                }
8282            }
8283        }
8284        if retired > 0 {
8285            let remaining_unavailable = self
8286                .session_records
8287                .lock()
8288                .unwrap_or_else(|poisoned| poisoned.into_inner())
8289                .records
8290                .values()
8291                .filter(|record| record.state == ManagedSessionState::Unavailable)
8292                .count();
8293            tracing::info!(
8294                node_id = %self.node_id,
8295                retired,
8296                remaining_unavailable,
8297                "session record retention sweep completed"
8298            );
8299        }
8300    }
8301
8302    async fn resume_session_record(
8303        &self,
8304        record_id: &SessionRecordId,
8305        terminal_size: gate4agent_types::TerminalSize,
8306        initial_prompt: Option<String>,
8307    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8308        let record = self.record(record_id)?;
8309        if record.active_session.is_some()
8310            || matches!(
8311                record.state,
8312                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8313            )
8314        {
8315            return Err(failure(
8316                NodeFailureCode::SessionRecordBusy,
8317                "managed session already has a live runtime binding",
8318            ));
8319        }
8320        let identity = record.provider_session.clone().ok_or_else(|| {
8321            failure(
8322                NodeFailureCode::SessionRecordNotResumable,
8323                "managed session has no verified provider session identity",
8324            )
8325        })?;
8326        let working_directory = self.workspace_root(&record.workspace_id)?;
8327        if !platform::roots_equal(
8328            &working_directory,
8329            windows_path_text(&record.canonical_root),
8330        ) {
8331            return Err(failure(
8332                NodeFailureCode::SessionRecordConflict,
8333                "managed session workspace root changed; refusing to resume in another directory",
8334            ));
8335        }
8336        {
8337            let records = self
8338                .session_records
8339                .lock()
8340                .unwrap_or_else(|poisoned| poisoned.into_inner());
8341            if records.records.values().any(|candidate| {
8342                candidate.record_id != *record_id
8343                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8344                        session_registry::same_provider_session(
8345                            &candidate.provider,
8346                            candidate_identity,
8347                            &record.provider,
8348                            &identity,
8349                        )
8350                    })
8351                    && (candidate.active_session.is_some()
8352                        || matches!(
8353                            candidate.state,
8354                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8355                        ))
8356            }) {
8357                return Err(failure(
8358                    NodeFailureCode::SessionRecordConflict,
8359                    "provider session is already active through another managed record",
8360                ));
8361            }
8362        }
8363        let runtime_requirement = if initial_prompt.is_some() {
8364            ProviderRuntimeRequirement::ResumeWithPrompt
8365        } else {
8366            ProviderRuntimeRequirement::Resume
8367        };
8368        let runtime_policy = self.admit_provider_runtime(
8369            &record.provider,
8370            runtime_requirement,
8371        )
8372        .await?;
8373        let request = ResumeLaunchRequest {
8374            working_directory,
8375            terminal_size,
8376            initial_prompt,
8377        };
8378        request
8379            .validate()
8380            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8381        self.ensure_binding_capacity()?;
8382        let resolved_materialization = match self.resolve_record_materialization(
8383            record_id,
8384            &record.provider,
8385            record.mode,
8386            record.environment_profile.as_ref(),
8387            record.bundle.as_ref(),
8388            record.context.as_ref(),
8389        ) {
8390            Ok(materialization) => materialization,
8391            Err(error) => {
8392                let label = match error.code {
8393                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8394                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8395                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8396                    _ => "environment-profile-unavailable",
8397                };
8398                self.mark_record_error(record_id, label)?;
8399                return Err(error);
8400            }
8401        };
8402        let (materialization_id, environment_overlay) = match resolved_materialization {
8403            Some((id, overlay)) => (Some(id), overlay),
8404            None => (None, None),
8405        };
8406        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8407        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8408            instance_id,
8409            &record.provider,
8410            record.mode,
8411            record.environment_profile.as_ref(),
8412        ) {
8413            Ok(selection) => selection,
8414            Err(error) => {
8415                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8416                return Err(error);
8417            }
8418        };
8419        let mut instance_overlay = environment_overlay
8420            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8421            .transpose()?
8422            .flatten();
8423        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8424            self.remove_session(&stale_address).await?;
8425        }
8426        let address = SessionAddress {
8427            workspace_id: record.workspace_id.clone(),
8428            session: SessionKey {
8429                instance_id,
8430                generation: SessionGeneration(1),
8431            },
8432        };
8433        let transaction = self
8434            .state_transaction
8435            .lock()
8436            .unwrap_or_else(|poisoned| poisoned.into_inner());
8437        let current = self.record(record_id)?;
8438        if current.active_session.is_some()
8439            || matches!(
8440                current.state,
8441                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8442            )
8443        {
8444            return Err(failure(
8445                NodeFailureCode::SessionRecordBusy,
8446                "managed session became active while resume was being prepared",
8447            ));
8448        }
8449        self.bind_managed_session_with_materialization(
8450            &address,
8451            record_id.clone(),
8452            runtime_policy,
8453            record.environment_profile.clone(),
8454            record.bundle.clone(),
8455            record.context.clone(),
8456            materialization_id,
8457        );
8458        if let Some(selection) = environment_selection.take() {
8459            selection.retain();
8460        }
8461        if let Some(overlay) = instance_overlay.take() {
8462            overlay.retain();
8463        }
8464        drop(transaction);
8465
8466        let transport = match record.mode {
8467            SessionMode::Pty => TransportKind::Pty,
8468            SessionMode::Inline => TransportKind::Pipe,
8469            SessionMode::Acp => TransportKind::Acp,
8470        };
8471        let agent_id = record.provider.clone();
8472        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8473        if let Err(error) = self
8474            .dispatch_bounded(
8475                ControlCommand::Register {
8476                    instance_id,
8477                    agent_id,
8478                    transport,
8479                },
8480                dispatch_timeout,
8481            )
8482            .await
8483        {
8484            if let Some(binding) = self.remove_binding(&address) {
8485                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8486            }
8487            return Err(error);
8488        }
8489        let command = self.prepare_command(ControlCommand::Resume {
8490            instance_id,
8491            target: ResumeTarget::ProviderSession { identity },
8492            runtime_policy,
8493            request,
8494        });
8495        let started_after = self.current_sequence();
8496        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8497            self.rollback_spawn(&address, dispatch_timeout).await?;
8498            return Err(error);
8499        }
8500        if let Err(error) = self.dispatch_envelope(command) {
8501            self.clear_armed_resume(&address);
8502            self.rollback_spawn(&address, dispatch_timeout).await?;
8503            return Err(error);
8504        }
8505        match self
8506            .wait_for_record_resume(&address, started_after)
8507            .await
8508        {
8509            Ok(settled_address) => {
8510                let managed_lease_id = {
8511                    self.managed_worktrees.lock()
8512                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8513                        .lease_for_workspace(&record.workspace_id)
8514                };
8515                if let Some(lease_id) = managed_lease_id {
8516                    {
8517                        let mut bindings = self.session_bindings.lock()
8518                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8519                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8520                            .ok_or_else(|| failure(
8521                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8522                                "resumed managed session lost its exact binding",
8523                            ))?;
8524                        if binding.generation != settled_address.session.generation {
8525                            return Err(failure(
8526                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8527                                "resumed managed session generation diverged",
8528                            ));
8529                        }
8530                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8531                    }
8532                    let snapshot = self.managed_worktrees.lock()
8533                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8534                        .bind_session(
8535                            &lease_id,
8536                            ManagedWorktreeSessionHolder {
8537                                incarnation_id: self.incarnation_id,
8538                                instance_id: settled_address.session.instance_id,
8539                                generation: settled_address.session.generation,
8540                            },
8541                            Some(record_id.clone()),
8542                            unix_time_ms(),
8543                        ).map_err(|message| failure(
8544                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8545                            &message,
8546                        ))?;
8547                    if let Err(error) = self.persist_state() {
8548                        let _ = self.remove_session(&settled_address).await;
8549                        return Err(persistence_failure(error));
8550                    }
8551                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8552                }
8553                Ok((self.record(record_id)?, settled_address))
8554            }
8555            Err(error) => {
8556                if matches!(
8557                    error.code,
8558                    NodeFailureCode::SessionRecordNotResumable
8559                        | NodeFailureCode::BackendOperationFailed
8560                ) {
8561                    if let Some(bound) = self.bound_address_for_record(record_id) {
8562                        let _ = self.remove_session(&bound).await;
8563                    }
8564                }
8565                Err(error)
8566            }
8567        }
8568    }
8569
8570    async fn wait_for_record_resume(
8571        &self,
8572        initial_address: &SessionAddress,
8573        after_sequence: u64,
8574    ) -> Result<SessionAddress, NodeFailure> {
8575        let deadline = Instant::now()
8576            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8577        let mut scan_after = after_sequence;
8578        loop {
8579            let events = self
8580                .history
8581                .lock()
8582                .unwrap_or_else(|poisoned| poisoned.into_inner())
8583                .events
8584                .iter()
8585                .filter(|event| event.sequence > scan_after)
8586                .cloned()
8587                .collect::<Vec<_>>();
8588            for envelope in events {
8589                scan_after = scan_after.max(envelope.sequence);
8590                let NodeEvent::Control { address, event } = envelope.event else {
8591                    continue;
8592                };
8593                if address.session.instance_id != initial_address.session.instance_id {
8594                    continue;
8595                }
8596                match event.event {
8597                    ControlEventKind::Resumed { .. } => return Ok(address),
8598                    ControlEventKind::ResumeDenied { reason } => {
8599                        return Err(failure(
8600                            NodeFailureCode::SessionRecordNotResumable,
8601                            &reason,
8602                        ));
8603                    }
8604                    ControlEventKind::ResumeFailed { message }
8605                    | ControlEventKind::CommandRejected { message } => {
8606                        return Err(failure(
8607                            NodeFailureCode::BackendOperationFailed,
8608                            &message,
8609                        ));
8610                    }
8611                    _ => {}
8612                }
8613            }
8614            if Instant::now() >= deadline {
8615                return Err(failure(
8616                    NodeFailureCode::BackendBusy,
8617                    "managed resume did not settle before the bounded deadline",
8618                ));
8619            }
8620            sleep(Duration::from_millis(5)).await;
8621        }
8622    }
8623
8624    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8625        self.workspaces
8626            .read()
8627            .unwrap_or_else(|poisoned| poisoned.into_inner())
8628            .get(workspace_id)
8629            .cloned()
8630            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8631    }
8632
8633    fn workspace_roots(&self) -> Vec<PathBuf> {
8634        self.workspaces
8635            .read()
8636            .unwrap_or_else(|poisoned| poisoned.into_inner())
8637            .values()
8638            .map(PathBuf::from)
8639            .collect()
8640    }
8641
8642    async fn inspect_workspace(
8643        &self,
8644        workspace_id: WorkspaceId,
8645    ) -> Result<WorkspaceInspection, NodeFailure> {
8646        let _permit = self
8647            .inspection_slots
8648            .clone()
8649            .try_acquire_owned()
8650            .map_err(|_| {
8651                tracing::warn!(
8652                    workspace_id = %workspace_id,
8653                    "workspace inspection rejected: inspection capacity is busy",
8654                );
8655                failure(
8656                    NodeFailureCode::BackendBusy,
8657                    "workspace inspection capacity is busy",
8658                )
8659            })?;
8660        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8661            tracing::warn!(
8662                workspace_id = %workspace_id,
8663                code = ?error.code,
8664                "workspace inspection rejected: workspace is not registered",
8665            );
8666            error
8667        })?;
8668        let tree_root = canonical_root.clone();
8669        let started_at = Instant::now();
8670        let time_budget_ms = workspace_inspection_time_budget_ms();
8671        let entry_cap = workspace_inspection_entry_cap();
8672        let deadline = started_at + Duration::from_millis(time_budget_ms);
8673        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8674            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8675        })
8676        .await
8677        .map_err(|error| {
8678            tracing::warn!(
8679                workspace_id = %workspace_id,
8680                error = %error,
8681                "workspace inspection rejected: walk task failed",
8682            );
8683            failure(
8684                NodeFailureCode::BackendOperationFailed,
8685                &format!("workspace inspection task failed: {error}"),
8686            )
8687        })?;
8688        let (mut git, git_time_budget_exceeded) =
8689            inspect_git_workspace(&canonical_root, deadline).await;
8690        if !git.worktrees.is_empty() {
8691            let registered = self
8692                .workspaces
8693                .read()
8694                .unwrap_or_else(|poisoned| poisoned.into_inner())
8695                .iter()
8696                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8697                .collect::<Vec<_>>();
8698            for worktree in &mut git.worktrees {
8699                worktree.workspace_id = registered
8700                    .iter()
8701                    .find(|(_, root)| {
8702                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8703                    })
8704                    .map(|(workspace_id, _)| workspace_id.clone());
8705            }
8706        }
8707        if git.is_repository {
8708            git.managed_worktree = self
8709                .managed_worktrees
8710                .lock()
8711                .unwrap_or_else(|poisoned| poisoned.into_inner())
8712                .git_scope(&workspace_id, git.branch.as_deref());
8713        }
8714        let truncation = workspace_inspection_truncation(
8715            &walk_budget,
8716            git_time_budget_exceeded,
8717            started_at.elapsed(),
8718        );
8719        if let Some(truncation) = &truncation {
8720            tracing::info!(
8721                workspace_id = %workspace_id,
8722                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8723                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8724                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8725                entries_visited = truncation.entries_visited,
8726                elapsed_ms = truncation.elapsed_ms,
8727                "workspace inspection truncated by its inner budget",
8728            );
8729        }
8730        Ok(WorkspaceInspection {
8731            workspace_id,
8732            entries,
8733            tree_truncated,
8734            git,
8735            truncation,
8736        })
8737    }
8738
8739    async fn browse_host_directories(
8740        &self,
8741        directory: Option<OpaqueHostPath>,
8742        after: Option<OpaqueHostPath>,
8743    ) -> Result<HostDirectoryListing, NodeFailure> {
8744        let permit = self
8745            .inspection_slots
8746            .clone()
8747            .try_acquire_owned()
8748            .map_err(|_| {
8749                failure(
8750                    NodeFailureCode::BackendBusy,
8751                    "host directory browse capacity is busy",
8752                )
8753            })?;
8754        let task = tokio::task::spawn_blocking(move || {
8755            let _permit = permit;
8756            browse_host_directories(directory, after)
8757        });
8758        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8759            .await
8760            .map_err(|_| {
8761                failure(
8762                    NodeFailureCode::HostDirectoryReadTimedOut,
8763                    "host directory browse exceeded its bounded deadline",
8764                )
8765            })?
8766            .map_err(|_| {
8767                failure(
8768                    NodeFailureCode::HostDirectoryReadFailed,
8769                    "host directory browse task failed",
8770                )
8771            })?
8772            .map_err(host_directory_failure)
8773    }
8774
8775    async fn read_workspace_file(
8776        &self,
8777        workspace_id: WorkspaceId,
8778        path: RepositoryPath,
8779    ) -> Result<WorkspaceFileRead, NodeFailure> {
8780        let canonical_root = self.workspace_root(&workspace_id)?;
8781        let permit = self
8782            .inspection_slots
8783            .clone()
8784            .try_acquire_owned()
8785            .map_err(|_| {
8786                failure(
8787                    NodeFailureCode::BackendBusy,
8788                    "workspace file read capacity is busy",
8789                )
8790            })?;
8791        let path_to_read = path.clone();
8792        let task = tokio::task::spawn_blocking(move || {
8793            let _permit = permit;
8794            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8795        });
8796        let content = timeout(
8797            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8798            task,
8799        )
8800        .await
8801        .map_err(|_| {
8802            failure(
8803                NodeFailureCode::RepositoryFileReadTimedOut,
8804                "repository file read exceeded its bounded deadline",
8805            )
8806        })?
8807        .map_err(|_| {
8808            failure(
8809                NodeFailureCode::RepositoryFileReadFailed,
8810                "repository file read task failed",
8811            )
8812        })?
8813        .map_err(workspace_file_failure)?;
8814        let content = match content {
8815            WorkspaceFileBytes::Utf8(text) => {
8816                let revision = Some(workspace_file_revision(text.as_bytes()));
8817                return Ok(WorkspaceFileRead {
8818                    workspace_id,
8819                    path,
8820                    content: WorkspaceFileContent::Utf8 {
8821                        byte_len: u32::try_from(text.len())
8822                            .expect("bounded workspace text length must fit u32"),
8823                        text,
8824                    },
8825                    revision,
8826                });
8827            }
8828            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8829                WorkspaceFileContent::NonUtf8 {
8830                    byte_len: u32::try_from(byte_length)
8831                        .expect("bounded workspace file length must fit u32"),
8832                }
8833            }
8834            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8835                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8836            },
8837        };
8838        Ok(WorkspaceFileRead {
8839            workspace_id,
8840            path,
8841            content,
8842            revision: None,
8843        })
8844    }
8845
8846    async fn write_workspace_file(
8847        &self,
8848        workspace_id: WorkspaceId,
8849        path: RepositoryPath,
8850        expected_revision: WorkspaceFileRevision,
8851        text: String,
8852    ) -> Result<WorkspaceFileRead, NodeFailure> {
8853        let canonical_root = self.workspace_root(&workspace_id)?;
8854        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8855            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8856        })?;
8857        let path_to_write = path.clone();
8858        let expected = expected_revision.as_str().to_owned();
8859        let text_to_write = text.clone();
8860        let task = tokio::task::spawn_blocking(move || {
8861            let _permit = permit;
8862            write_workspace_file_to_disk(
8863                Path::new(&canonical_root),
8864                &path_to_write,
8865                &expected,
8866                &text_to_write,
8867            )
8868        });
8869        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8870            .await
8871            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8872            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8873            .map_err(workspace_file_write_failure)?;
8874        Ok(WorkspaceFileRead {
8875            workspace_id,
8876            path,
8877            content: WorkspaceFileContent::Utf8 {
8878                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8879                text,
8880            },
8881            revision: Some(WorkspaceFileRevision::new(revision)
8882                .expect("workspace writer returns a valid SHA-256 revision")),
8883        })
8884    }
8885
8886    async fn create_workspace_file(
8887        &self,
8888        workspace_id: WorkspaceId,
8889        path: RepositoryPath,
8890    ) -> Result<WorkspaceFileRead, NodeFailure> {
8891        let canonical_root = self.workspace_root(&workspace_id)?;
8892        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8893            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8894        })?;
8895        let path_to_create = path.clone();
8896        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8897        let task_commit_state = Arc::clone(&commit_state);
8898        let task = tokio::task::spawn_blocking(move || {
8899            let _permit = permit;
8900            create_workspace_file_on_disk(
8901                Path::new(&canonical_root),
8902                &path_to_create,
8903                &task_commit_state,
8904            )
8905        });
8906        let revision = settle_workspace_entry_create(
8907            task,
8908            commit_state,
8909            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8910            "repository file create exceeded its bounded deadline",
8911            "repository file create task failed",
8912        )
8913        .await?;
8914        Ok(WorkspaceFileRead {
8915            workspace_id,
8916            path,
8917            content: WorkspaceFileContent::Utf8 {
8918                text: String::new(),
8919                byte_len: 0,
8920            },
8921            revision: Some(WorkspaceFileRevision::new(revision)
8922                .expect("workspace creator returns a valid SHA-256 revision")),
8923        })
8924    }
8925
8926    async fn create_workspace_directory(
8927        &self,
8928        workspace_id: WorkspaceId,
8929        path: RepositoryPath,
8930    ) -> Result<WorkspaceEntry, NodeFailure> {
8931        let canonical_root = self.workspace_root(&workspace_id)?;
8932        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8933            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8934        })?;
8935        let path_to_create = path.clone();
8936        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8937        let task_commit_state = Arc::clone(&commit_state);
8938        let task = tokio::task::spawn_blocking(move || {
8939            let _permit = permit;
8940            create_workspace_directory_on_disk(
8941                Path::new(&canonical_root),
8942                &path_to_create,
8943                &task_commit_state,
8944            )
8945        });
8946        settle_workspace_entry_create(
8947            task,
8948            commit_state,
8949            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8950            "repository directory create exceeded its bounded deadline",
8951            "repository directory create task failed",
8952        )
8953        .await?;
8954        Ok(WorkspaceEntry {
8955            relative_path: path,
8956            kind: WorkspaceEntryKind::Directory,
8957        })
8958    }
8959
8960    async fn read_git_history(
8961        &self,
8962        workspace_id: WorkspaceId,
8963        path: Option<RepositoryPath>,
8964        before: Option<GitObjectId>,
8965        limit: u16,
8966    ) -> Result<GitHistoryPage, NodeFailure> {
8967        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8968            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8969        })?;
8970        let root = self.workspace_root(&workspace_id)?;
8971        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8972    }
8973
8974    async fn read_git_diff(
8975        &self,
8976        workspace_id: WorkspaceId,
8977        request: GitDiffRequest,
8978    ) -> Result<GitDiff, NodeFailure> {
8979        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8980            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8981        })?;
8982        let root = self.workspace_root(&workspace_id)?;
8983        read_git_diff_bounded(&root, request).await
8984    }
8985
8986    fn native_session_route_root(
8987        &self,
8988        route: &NativeSessionCatalogRoute,
8989    ) -> Result<Option<String>, NodeFailure> {
8990        route
8991            .validate()
8992            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8993        match route.scope {
8994            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8995                .workspace_id
8996                .as_ref()
8997                .map(|workspace_id| self.workspace_root(workspace_id))
8998                .transpose(),
8999            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9000        }
9001    }
9002
9003    fn project_native_session_entries(
9004        &self,
9005        route: &NativeSessionCatalogRoute,
9006        entries: Vec<ScopedNativeSessionCatalogEntry>,
9007    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9008        let current_root = route
9009            .workspace_id
9010            .as_ref()
9011            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9012        let records = self
9013            .session_records
9014            .lock()
9015            .unwrap_or_else(|poisoned| poisoned.into_inner());
9016        let mut seen_records = std::collections::HashSet::new();
9017        entries
9018            .into_iter()
9019            .map(|entry| {
9020                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9021                    let matches = records
9022                        .records
9023                        .values()
9024                        .filter(|record| {
9025                            record.provider == route.provider
9026                                && record.workspace_id == *workspace_id
9027                                && current_root.as_deref().is_some_and(|root| {
9028                                    platform::roots_equal(
9029                                        root,
9030                                        windows_path_text(&record.canonical_root),
9031                                    )
9032                                })
9033                                && record.provider_session.as_ref().is_some_and(|identity| {
9034                                    session_registry::same_provider_session(
9035                                        &record.provider,
9036                                        identity,
9037                                        &route.provider,
9038                                        &entry.provider_session,
9039                                    )
9040                                })
9041                        })
9042                        .map(|record| record.record_id.clone())
9043                        .collect::<Vec<_>>();
9044                    (matches.len() == 1
9045                        && seen_records.insert(matches[0].clone()))
9046                        .then(|| matches[0].clone())
9047                });
9048                let metadata = entry.metadata;
9049                crate::protocol::NativeSessionCatalogEntry {
9050                    selection_id: metadata.selection_id,
9051                    title: metadata.title,
9052                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9053                    model: metadata.model,
9054                    message_count: metadata.message_count,
9055                    completed_turn_count: metadata.completed_turn_count,
9056                    external_group: entry.external_group,
9057                    record_id,
9058                }
9059            })
9060            .collect()
9061    }
9062
9063    async fn catalog_native_sessions(
9064        &self,
9065        route: NativeSessionCatalogRoute,
9066        limit: u16,
9067    ) -> Result<(
9068        Vec<crate::protocol::NativeSessionCatalogEntry>,
9069        crate::protocol::NativeSessionCatalogSummary,
9070    ), NodeFailure> {
9071        let canonical_root = self.native_session_route_root(&route)?;
9072        let registered_workspace_roots = self.workspace_roots();
9073        let provider = route.provider.clone();
9074        let scope = route.scope;
9075        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9076            failure(
9077                NodeFailureCode::InvalidRequest,
9078                "native session catalog is not configured",
9079            )
9080        })?;
9081        let _permit = Arc::clone(&self.inspection_slots)
9082            .try_acquire_owned()
9083            .map_err(|_| {
9084                failure(
9085                    NodeFailureCode::BackendBusy,
9086                    "native session catalog capacity is busy",
9087                )
9088            })?;
9089        let page = run_native_session_catalog_operation(
9090            catalog,
9091            "native session catalog",
9092            move |catalog| {
9093                catalog.catalog_initial_for_scope(
9094                    &provider,
9095                    scope,
9096                    canonical_root.as_deref().map(Path::new),
9097                    &registered_workspace_roots,
9098                    limit,
9099                )
9100            },
9101        )
9102        .await?;
9103        let summary = crate::protocol::NativeSessionCatalogSummary {
9104            catalog_revision: page.revision,
9105            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9106            recent_total_count: page.recent_total_count,
9107            older_total_count: page.older_total_count,
9108            recent_next_after_selection_id: page.next_after_selection_id,
9109            recent_has_more: page.remaining_count > 0,
9110        };
9111        Ok((self.project_native_session_entries(&route, page.entries), summary))
9112    }
9113
9114    async fn page_native_sessions(
9115        &self,
9116        route: NativeSessionCatalogRoute,
9117        window: crate::protocol::NativeSessionCatalogWindow,
9118        catalog_revision: u64,
9119        recent_cutoff_unix_ms: u64,
9120        after_selection_id: Option<String>,
9121        limit: u16,
9122    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9123        let canonical_root = self.native_session_route_root(&route)?;
9124        let registered_workspace_roots = self.workspace_roots();
9125        let provider = route.provider.clone();
9126        let scope = route.scope;
9127        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9128            failure(
9129                NodeFailureCode::InvalidRequest,
9130                "native session catalog is not configured",
9131            )
9132        })?;
9133        let _permit = Arc::clone(&self.inspection_slots)
9134            .try_acquire_owned()
9135            .map_err(|_| {
9136                failure(
9137                    NodeFailureCode::BackendBusy,
9138                    "native session catalog capacity is busy",
9139                )
9140            })?;
9141        let page = run_native_session_catalog_operation(
9142            catalog,
9143            "native session catalog page",
9144            move |catalog| {
9145                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9146                    &provider,
9147                    scope,
9148                    canonical_root.as_deref().map(Path::new),
9149                    &registered_workspace_roots,
9150                    window,
9151                    catalog_revision,
9152                    recent_cutoff_unix_ms,
9153                    after_selection_id.as_deref(),
9154                    limit,
9155                ))
9156            },
9157        )
9158        .await?;
9159        let page = page.map_err(|error| match error {
9160            NativeSessionCatalogError::StaleCatalog => failure(
9161                NodeFailureCode::StaleNativeSessionCatalog,
9162                "native session catalog revision or cursor is stale",
9163            ),
9164            _ => failure(
9165                NodeFailureCode::BackendOperationFailed,
9166                "native session catalog page failed",
9167            ),
9168        })?;
9169        let entries = self.project_native_session_entries(&route, page.entries);
9170        Ok(crate::protocol::NativeSessionCatalogPage {
9171            window: page.window,
9172            revision: page.revision,
9173            entries,
9174            next_after_selection_id: page.next_after_selection_id,
9175            remaining_count: page.remaining_count,
9176            has_more: page.remaining_count > 0,
9177        })
9178    }
9179
9180    async fn preview_native_session(
9181        &self,
9182        selection: NativeSessionSelection,
9183        message_limit: u16,
9184    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9185        selection
9186            .validate()
9187            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9188        let canonical_root = self.native_session_route_root(&selection.route)?;
9189        let registered_workspace_roots = self.workspace_roots();
9190        let provider = selection.route.provider.clone();
9191        let scope = selection.route.scope;
9192        let catalog_revision = selection.catalog_revision;
9193        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9194        let selection_id = selection.selection_id.clone();
9195        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9196            failure(
9197                NodeFailureCode::InvalidRequest,
9198                "native session preview is not configured",
9199            )
9200        })?;
9201        let _permit = Arc::clone(&self.inspection_slots)
9202            .try_acquire_owned()
9203            .map_err(|_| {
9204                failure(
9205                    NodeFailureCode::BackendBusy,
9206                    "native session preview capacity is busy",
9207                )
9208            })?;
9209        let preview = run_native_session_catalog_operation(
9210            catalog,
9211            "native session preview",
9212            move |catalog| {
9213                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9214                    &provider,
9215                    scope,
9216                    canonical_root.as_deref().map(Path::new),
9217                    &registered_workspace_roots,
9218                    catalog_revision,
9219                    recent_cutoff_unix_ms,
9220                    &selection_id,
9221                    message_limit,
9222                ))
9223            },
9224        )
9225        .await?;
9226        preview
9227            .map(Into::into)
9228            .map_err(native_session_preview_failure)
9229    }
9230
9231    async fn index_native_session(
9232        &self,
9233        selection: NativeSessionSelection,
9234        display_name: String,
9235    ) -> Result<ManagedSessionRecord, NodeFailure> {
9236        selection
9237            .validate()
9238            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9239        if selection.route.scope
9240            != gate4agent_types::NativeSessionCatalogScope::Workspace
9241        {
9242            return Err(failure(
9243                NodeFailureCode::WorkspaceRegistrationRequired,
9244                "register the external project as a workspace before indexing or resuming it",
9245            ));
9246        }
9247        let workspace_id = selection
9248            .route
9249            .workspace_id
9250            .clone()
9251            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9252        let canonical_root = self
9253            .native_session_route_root(&selection.route)?
9254            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9255        let registered_workspace_roots = self.workspace_roots();
9256        let provider = selection.route.provider.clone();
9257        let catalog_revision = selection.catalog_revision;
9258        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9259        let selection_id = selection.selection_id.clone();
9260        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9261            failure(
9262                NodeFailureCode::InvalidRequest,
9263                "native session index is not configured",
9264            )
9265        })?;
9266        let _permit = Arc::clone(&self.inspection_slots)
9267            .try_acquire_owned()
9268            .map_err(|_| {
9269                failure(
9270                    NodeFailureCode::BackendBusy,
9271                    "native session index capacity is busy",
9272                )
9273            })?;
9274        let resolved = run_native_session_catalog_operation(
9275            catalog,
9276            "native session index resolution",
9277            move |catalog| {
9278                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9279                    &provider,
9280                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9281                    Some(Path::new(&canonical_root)),
9282                    &registered_workspace_roots,
9283                    catalog_revision,
9284                    recent_cutoff_unix_ms,
9285                    &selection_id,
9286                ))
9287            },
9288        )
9289        .await?
9290        .map_err(native_session_preview_failure)?;
9291        self.index_provider_session_with_policy(
9292            workspace_id,
9293            selection.route.provider,
9294            resolved.identity,
9295            display_name,
9296            true,
9297        )
9298    }
9299
9300    async fn preview_native_session_by_session_id(
9301        &self,
9302        workspace_id: WorkspaceId,
9303        provider: AgentId,
9304        session_id: String,
9305        message_limit: u16,
9306    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9307        let canonical_root = self.workspace_root(&workspace_id)?;
9308        let registered_workspace_roots = self.workspace_roots();
9309        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9310            failure(
9311                NodeFailureCode::InvalidRequest,
9312                "native session preview is not configured",
9313            )
9314        })?;
9315        let _permit = Arc::clone(&self.inspection_slots)
9316            .try_acquire_owned()
9317            .map_err(|_| {
9318                failure(
9319                    NodeFailureCode::BackendBusy,
9320                    "native session preview capacity is busy",
9321                )
9322            })?;
9323        run_native_session_catalog_operation(
9324            catalog,
9325            "native session preview",
9326            move |catalog| {
9327                catalog.preview_session_id_for_workspace(
9328                    &provider,
9329                    Path::new(&canonical_root),
9330                    &registered_workspace_roots,
9331                    &session_id,
9332                    message_limit,
9333                )
9334            },
9335        )
9336        .await
9337    }
9338
9339    async fn register_workspace(
9340        &self,
9341        workspace_id: WorkspaceId,
9342        root: String,
9343    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9344        validate_workspace_request_root(&root)?;
9345        let canonicalize_workspace_id = workspace_id.clone();
9346        let workspace = tokio::task::spawn_blocking(move || {
9347            WorkspaceConfig::new(canonicalize_workspace_id, root)
9348        })
9349        .await
9350        .map_err(|error| {
9351            failure(
9352                NodeFailureCode::BackendOperationFailed,
9353                &format!("workspace canonicalization task failed: {error}"),
9354            )
9355        })?
9356        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9357        let transaction = self
9358            .state_transaction
9359            .lock()
9360            .unwrap_or_else(|poisoned| poisoned.into_inner());
9361        let mut workspaces = self
9362            .workspaces
9363            .write()
9364            .unwrap_or_else(|poisoned| poisoned.into_inner());
9365        if workspaces.contains_key(&workspace_id) {
9366            return Err(failure(
9367                NodeFailureCode::DuplicateWorkspaceId,
9368                "workspace ID is already registered",
9369            ));
9370        }
9371        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9372            platform::roots_equal(existing_root, workspace.canonical_root())
9373        }) {
9374            return Err(failure(
9375                NodeFailureCode::DuplicateWorkspaceRoot,
9376                &format!("workspace root is already registered as '{existing_id}'"),
9377            ));
9378        }
9379        {
9380            let records = self
9381                .session_records
9382                .lock()
9383                .unwrap_or_else(|poisoned| poisoned.into_inner());
9384            if let Some(record) = records.records.values().find(|record| {
9385                record.workspace_id == workspace_id
9386                    && !record
9387                        .canonical_root
9388                        .as_utf8()
9389                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9390            }) {
9391                return Err(failure(
9392                    NodeFailureCode::SessionRecordConflict,
9393                    &format!(
9394                        "workspace ID is retained by session record '{}' at another root",
9395                        record.record_id,
9396                    ),
9397                ));
9398            }
9399        }
9400        let snapshot = WorkspaceSnapshot {
9401            workspace_id: workspace_id.clone(),
9402            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9403            sessions: Vec::new(),
9404            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9405            managed_worktree_profiles: Some(WorktreeProfileInventory {
9406                profiles: Vec::new(),
9407            }),
9408        };
9409        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9410        drop(workspaces);
9411        let previous_records = {
9412            let mut records = self
9413                .session_records
9414                .lock()
9415                .unwrap_or_else(|poisoned| poisoned.into_inner());
9416            let previous = records.records.clone();
9417            for record in records.records.values_mut().filter(|record| {
9418                record.workspace_id == workspace_id
9419                    && record
9420                        .canonical_root
9421                        .as_utf8()
9422                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9423            }) {
9424                if record.active_session.is_none() {
9425                    record.state = detached_record_state(record);
9426                }
9427                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9428                    record.last_error = None;
9429                }
9430                record.updated_at_unix_ms = unix_time_ms();
9431            }
9432            previous
9433        };
9434        if let Err(error) = self.persist_state_locked() {
9435            self.workspaces
9436                .write()
9437                .unwrap_or_else(|poisoned| poisoned.into_inner())
9438                .remove(&workspace_id);
9439            self.session_records
9440                .lock()
9441                .unwrap_or_else(|poisoned| poisoned.into_inner())
9442                .records = previous_records;
9443            return Err(persistence_failure(error));
9444        }
9445        let restored = self
9446            .session_records
9447            .lock()
9448            .unwrap_or_else(|poisoned| poisoned.into_inner())
9449            .records
9450            .values()
9451            .filter(|record| record.workspace_id == workspace_id)
9452            .cloned()
9453            .collect::<Vec<_>>();
9454        drop(transaction);
9455        self.publish(NodeEvent::WorkspaceAdded {
9456            workspace: snapshot.clone(),
9457        });
9458        for record in restored {
9459            self.publish_record(record);
9460        }
9461        Ok(snapshot)
9462    }
9463
9464    async fn create_standalone_workspace(
9465        &self,
9466        workspace_id: WorkspaceId,
9467        root: String,
9468        initial_branch: Option<String>,
9469    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9470        validate_workspace_request_root(&root)?;
9471        validate_git_revision("initial branch", initial_branch.as_deref())?;
9472        let preflight_workspace_id = workspace_id.clone();
9473        let preflight_root = root.clone();
9474        let canonical_candidate = tokio::task::spawn_blocking(move || {
9475            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9476        })
9477        .await
9478        .map_err(|_| failure(
9479            NodeFailureCode::BackendOperationFailed,
9480            "standalone workspace root preflight task failed",
9481        ))?
9482        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9483        {
9484            let workspaces = self
9485                .workspaces
9486                .read()
9487                .unwrap_or_else(|poisoned| poisoned.into_inner());
9488            if workspaces.contains_key(&workspace_id) {
9489                return Err(failure(
9490                    NodeFailureCode::DuplicateWorkspaceId,
9491                    "workspace ID is already registered",
9492                ));
9493            }
9494            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9495                platform::roots_equal(existing_root, &canonical_candidate)
9496            }) {
9497                return Err(failure(
9498                    NodeFailureCode::DuplicateWorkspaceRoot,
9499                    &format!("workspace root is already registered as '{existing_id}'"),
9500                ));
9501            }
9502        }
9503        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9504            .await
9505            .map_err(standalone_workspace_failure)?;
9506        prepared
9507            .verify_for_registration()
9508            .await
9509            .map_err(standalone_workspace_failure)?;
9510        match self
9511            .register_workspace(workspace_id, prepared.root().to_owned())
9512            .await
9513        {
9514            Ok(workspace) => Ok(workspace),
9515            Err(registration_error) => {
9516                if prepared.compensate_registration_failure().await {
9517                    Err(registration_error)
9518                } else {
9519                    Err(failure(
9520                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9521                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9522                    ))
9523                }
9524            }
9525        }
9526    }
9527
9528    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9529        let bindings = self
9530            .session_bindings
9531            .lock()
9532            .unwrap_or_else(|poisoned| poisoned.into_inner());
9533        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9534            return Err(failure(
9535                NodeFailureCode::WorkspaceBusy,
9536                "workspace retains a bound session; remove every session before unregistering it",
9537            ));
9538        }
9539        drop(bindings);
9540        let transaction = self
9541            .state_transaction
9542            .lock()
9543            .unwrap_or_else(|poisoned| poisoned.into_inner());
9544        let mut workspaces = self
9545            .workspaces
9546            .write()
9547            .unwrap_or_else(|poisoned| poisoned.into_inner());
9548        if !workspaces.contains_key(workspace_id) {
9549            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9550        }
9551        if workspaces.len() == 1 {
9552            return Err(failure(
9553                NodeFailureCode::LastWorkspace,
9554                "node must retain at least one workspace",
9555            ));
9556        }
9557        let removed_root = workspaces
9558            .remove(workspace_id)
9559            .expect("workspace presence was validated");
9560        drop(workspaces);
9561        let previous_records = {
9562            let mut records = self
9563                .session_records
9564                .lock()
9565                .unwrap_or_else(|poisoned| poisoned.into_inner());
9566            let previous = records.records.clone();
9567            for record in records
9568                .records
9569                .values_mut()
9570                .filter(|record| &record.workspace_id == workspace_id)
9571            {
9572                record.active_session = None;
9573                record.state = ManagedSessionState::Unavailable;
9574                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9575                record.updated_at_unix_ms = unix_time_ms();
9576            }
9577            previous
9578        };
9579        if let Err(error) = self.persist_state_locked() {
9580            self.workspaces
9581                .write()
9582                .unwrap_or_else(|poisoned| poisoned.into_inner())
9583                .insert(workspace_id.clone(), removed_root);
9584            self.session_records
9585                .lock()
9586                .unwrap_or_else(|poisoned| poisoned.into_inner())
9587                .records = previous_records;
9588            return Err(persistence_failure(error));
9589        }
9590        let unavailable = self
9591            .session_records
9592            .lock()
9593            .unwrap_or_else(|poisoned| poisoned.into_inner())
9594            .records
9595            .values()
9596            .filter(|record| &record.workspace_id == workspace_id)
9597            .cloned()
9598            .collect::<Vec<_>>();
9599        drop(transaction);
9600        self.publish(NodeEvent::WorkspaceRemoved {
9601            workspace_id: workspace_id.clone(),
9602        });
9603        for record in unavailable {
9604            self.publish_record(record);
9605        }
9606        Ok(())
9607    }
9608
9609    async fn create_worktree(
9610        &self,
9611        source_workspace_id: WorkspaceId,
9612        workspace_id: WorkspaceId,
9613        target_root: String,
9614        branch: String,
9615        base: Option<String>,
9616    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9617        self.require_worktree_service(&source_workspace_id)?;
9618        validate_workspace_request_root(&target_root)?;
9619        validate_git_revision("worktree base", base.as_deref())?;
9620        let source_root = self.workspace_root(&source_workspace_id)?;
9621        {
9622            let workspaces = self
9623                .workspaces
9624                .read()
9625                .unwrap_or_else(|poisoned| poisoned.into_inner());
9626            if workspaces.contains_key(&workspace_id) {
9627                return Err(failure(
9628                    NodeFailureCode::DuplicateWorkspaceId,
9629                    "workspace ID is already registered",
9630                ));
9631            }
9632            if let Some((existing_id, _)) = workspaces
9633                .iter()
9634                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9635            {
9636                return Err(failure(
9637                    NodeFailureCode::DuplicateWorkspaceRoot,
9638                    &format!("workspace root is already registered as '{existing_id}'"),
9639                ));
9640            }
9641        }
9642        let mut worktree = create_git_worktree(
9643            &source_root,
9644            &target_root,
9645            &branch,
9646            base.as_deref(),
9647        )
9648        .await
9649        .map_err(git_worktree_failure)?;
9650        let workspace = match self
9651            .register_workspace(workspace_id, worktree.path.clone())
9652            .await
9653        {
9654            Ok(workspace) => workspace,
9655            Err(registration_error) => {
9656                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9657                    return Err(failure(
9658                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659                        "worktree registration failed and compensation requires recovery",
9660                    ));
9661                }
9662                return Err(failure(
9663                    registration_error.code,
9664                    &registration_error.message,
9665                ));
9666            }
9667        };
9668        worktree.workspace_id = Some(workspace.workspace_id.clone());
9669        Ok((protocol_worktree(worktree), workspace))
9670    }
9671
9672    async fn remove_worktree(
9673        &self,
9674        source_workspace_id: WorkspaceId,
9675        target_root: String,
9676    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9677        self.require_worktree_service(&source_workspace_id)?;
9678        validate_workspace_request_root(&target_root)?;
9679        let source_root = self.workspace_root(&source_workspace_id)?;
9680        let target_root = normalize_worktree_removal_target(&target_root)
9681            .map_err(git_worktree_failure)?;
9682        let target = list_git_worktrees(&source_root)
9683            .await
9684            .map_err(git_worktree_failure)?
9685            .into_iter()
9686            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9687            .ok_or_else(|| {
9688                failure(
9689                    NodeFailureCode::WorktreeProtected,
9690                    "refusing to remove a path that is not in Git's worktree listing",
9691                )
9692            })?;
9693        let registered_workspace_id = {
9694            let workspaces = self
9695                .workspaces
9696                .read()
9697                .unwrap_or_else(|poisoned| poisoned.into_inner());
9698            let matched = workspaces
9699                .iter()
9700                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9701                .map(|(workspace_id, _)| workspace_id.clone());
9702            if matched.is_some() && workspaces.len() == 1 {
9703                return Err(failure(
9704                    NodeFailureCode::LastWorkspace,
9705                    "node must retain at least one workspace",
9706                ));
9707            }
9708            matched
9709        };
9710        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9711            let bindings = self
9712                .session_bindings
9713                .lock()
9714                .unwrap_or_else(|poisoned| poisoned.into_inner());
9715            if bindings
9716                .values()
9717                .any(|binding| &binding.workspace_id == workspace_id)
9718            {
9719                return Err(failure(
9720                    NodeFailureCode::WorkspaceBusy,
9721                    "worktree workspace retains a bound session; remove every session first",
9722                ));
9723            }
9724        }
9725        remove_git_worktree(&source_root, &target.path)
9726            .await
9727            .map_err(git_worktree_failure)?;
9728        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9729            self.unregister_workspace(workspace_id)
9730                .map_err(|error| failure(error.code, &error.message))?;
9731        }
9732        Ok(registered_workspace_id)
9733    }
9734
9735    async fn cleanup_managed_worktree(
9736        &self,
9737        lease_id: &ManagedWorktreeLeaseId,
9738        explicit: bool,
9739    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9740        let lease = self.managed_worktrees.lock()
9741            .unwrap_or_else(|poisoned| poisoned.into_inner())
9742            .get(lease_id)
9743            .cloned()
9744            .ok_or_else(|| failure(
9745                NodeFailureCode::UnknownManagedWorktreeLease,
9746                "managed worktree lease does not exist",
9747            ))?;
9748        if lease.state == ManagedWorktreeLeaseState::Removed {
9749            return Ok(lease.snapshot());
9750        }
9751        if lease.has_holders() {
9752            return Err(failure(
9753                NodeFailureCode::ManagedWorktreeBusy,
9754                "managed worktree lease still has a session or durable-record holder",
9755            ));
9756        }
9757        if self
9758            .materializations
9759            .lock()
9760            .unwrap_or_else(|poisoned| poisoned.into_inner())
9761            .values()
9762            .any(|record| record.managed_lease_id() == Some(lease_id))
9763        {
9764            return Err(failure(
9765                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9766                "managed worktree cleanup is blocked by session environment ownership",
9767            ));
9768        }
9769        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9770            let snapshot = {
9771                let mut registry = self.managed_worktrees.lock()
9772                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9773                let current = registry.get_mut(lease_id)
9774                    .expect("managed worktree lease remains present");
9775                current.state = ManagedWorktreeLeaseState::Retained;
9776                current.cleanup_failure = None;
9777                current.updated_at_unix_ms = unix_time_ms();
9778                current.snapshot()
9779            };
9780            if self.persist_state().is_err() {
9781                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9782            }
9783            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9784            return Ok(snapshot);
9785        }
9786        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9787        if profile.revision() != &lease.profile_revision {
9788            self.mark_managed_recovery_required(
9789                lease_id,
9790                ManagedWorktreeCleanupFailure::OwnershipConflict,
9791            );
9792            return Err(failure(
9793                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9794                "managed worktree profile revision changed",
9795            ));
9796        }
9797        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9798            self.mark_managed_recovery_required(
9799                lease_id,
9800                ManagedWorktreeCleanupFailure::OwnershipConflict,
9801            );
9802            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9803        })?;
9804        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9805        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9806            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9807            managed_git_worktree_failure(error)
9808        })?;
9809        let target = listed.iter()
9810            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9811        if let Some(target) = target {
9812            if target.is_main || target.is_bare || !exact_owned_worktree(
9813                &lease,
9814                &target.path,
9815                target.branch.as_deref(),
9816                &target.head,
9817            ) {
9818                self.mark_managed_recovery_required(
9819                    lease_id,
9820                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9821                );
9822                return Err(failure(
9823                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9824                    "Git worktree identity no longer matches the managed lease",
9825                ));
9826            }
9827            if target.prunable {
9828                self.mark_managed_cleanup_blocked(
9829                    lease_id,
9830                    ManagedWorktreeCleanupFailure::Prunable,
9831                );
9832                return Err(failure(
9833                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9834                    "managed worktree is prunable and requires recovery",
9835                ));
9836            }
9837            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9838                let failure_kind = match error.kind {
9839                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9840                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9841                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9842                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9843                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9844                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9845                };
9846                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9847                    self.mark_managed_recovery_required(lease_id, failure_kind);
9848                } else {
9849                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9850                }
9851                return Err(managed_git_worktree_failure(error));
9852            }
9853        } else {
9854            match std::fs::symlink_metadata(&lease.target_root) {
9855                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9856                Ok(_) => {
9857                    self.mark_managed_recovery_required(
9858                        lease_id,
9859                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9860                    );
9861                    return Err(failure(
9862                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9863                        "managed target exists but is not owned by Git",
9864                    ));
9865                }
9866                Err(_) => {
9867                    self.mark_managed_cleanup_blocked(
9868                        lease_id,
9869                        ManagedWorktreeCleanupFailure::Backend,
9870                    );
9871                    return Err(failure(
9872                        NodeFailureCode::BackendOperationFailed,
9873                        "managed target identity could not be inspected",
9874                    ));
9875                }
9876            }
9877        }
9878        let registered = self.workspaces.read()
9879            .unwrap_or_else(|poisoned| poisoned.into_inner())
9880            .contains_key(&lease.workspace_id);
9881        if registered {
9882            self.unregister_workspace(&lease.workspace_id)?;
9883        }
9884        let removed = {
9885            self.managed_worktrees.lock()
9886                .unwrap_or_else(|poisoned| poisoned.into_inner())
9887                .tombstone(lease_id, unix_time_ms())
9888                .expect("managed worktree lease remains present during cleanup")
9889        };
9890        self.persist_state().map_err(persistence_failure)?;
9891        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9892        Ok(removed.snapshot())
9893    }
9894
9895    fn mark_managed_cleanup_blocked(
9896        &self,
9897        lease_id: &ManagedWorktreeLeaseId,
9898        failure_kind: ManagedWorktreeCleanupFailure,
9899    ) {
9900        let snapshot = {
9901            let mut registry = self.managed_worktrees.lock()
9902                .unwrap_or_else(|poisoned| poisoned.into_inner());
9903            let Some(lease) = registry.get_mut(lease_id) else { return };
9904            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9905            lease.cleanup_failure = Some(failure_kind);
9906            lease.updated_at_unix_ms = unix_time_ms();
9907            lease.snapshot()
9908        };
9909        let _ = self.persist_state();
9910        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9911    }
9912
9913    fn require_worktree_service(
9914        &self,
9915        source_workspace_id: &WorkspaceId,
9916    ) -> Result<(), NodeFailure> {
9917        if self.managed_worktrees.lock()
9918            .unwrap_or_else(|poisoned| poisoned.into_inner())
9919            .lease_for_workspace(source_workspace_id)
9920            .is_some()
9921        {
9922            return Err(failure(
9923                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9924                "managed worktree targets are internal-only workspaces",
9925            ));
9926        }
9927        match self
9928            .worktree_service_modes
9929            .get(source_workspace_id)
9930            .copied()
9931            .unwrap_or(WorktreeServiceMode::Manual)
9932        {
9933            WorktreeServiceMode::Manual => Ok(()),
9934            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9935                NodeFailureCode::UnsupportedSpawnCapability,
9936                "Git worktree service mode is unavailable in this node build",
9937            )),
9938        }
9939    }
9940
9941    fn managed_reservation_conflict(
9942        &self,
9943        workspace_id: Option<&WorkspaceId>,
9944        root: Option<&str>,
9945    ) -> bool {
9946        self.managed_worktrees.lock()
9947            .unwrap_or_else(|poisoned| poisoned.into_inner())
9948            .active_records()
9949            .any(|lease| {
9950                workspace_id.is_some_and(|workspace_id| {
9951                    workspace_id == &lease.source_workspace_id
9952                        || workspace_id == &lease.workspace_id
9953                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9954            })
9955    }
9956
9957    fn reject_managed_reservation(
9958        &self,
9959        workspace_id: Option<&WorkspaceId>,
9960        root: Option<&str>,
9961    ) -> Result<(), NodeFailure> {
9962        if self.managed_reservation_conflict(workspace_id, root) {
9963            Err(failure(
9964                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9965                "managed worktree ownership may only be mutated through its lease cleanup",
9966            ))
9967        } else {
9968            Ok(())
9969        }
9970    }
9971
9972    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9973        self.validate_address(address)?;
9974        self.workspace_root(&address.workspace_id)
9975    }
9976
9977    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9978    /// `require_policy`'s own doc on those two requirements), so they keep
9979    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9980    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9981    /// `false` on the `require_policy` call below for them because a PTY
9982    /// resume never needs those facts, not because this method cannot look
9983    /// them up.
9984    ///
9985    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9986    /// the SAME `binding.runtime_policy` every other requirement reads here
9987    /// (one computed policy, read by every gate). That policy is no longer
9988    /// PTY-terminal-verification-shaped for an ACP session --
9989    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9990    /// `provider_runtime::policy_for_transport` -- so consulting it for
9991    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9992    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9993    /// even though a prompt is an ACP session's only interface). Pipe keeps
9994    /// reading the same PTY-probe-derived value it always has --
9995    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9996    /// its own doc for why).
9997    ///
9998    /// What this method still adds on top, for ACP/Pipe only, is a
9999    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10000    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10001    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10002    /// read off the same live `SessionSnapshot` state `require_acp_session`
10003    /// already reads for the ACP control verbs -- `TurnStarted` observed
10004    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10005    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10006    /// its fate in vendor-specific behaviour this node cannot observe (one
10007    /// agent may queue it, another drop it, another interleave it), so this
10008    /// refuses it outright rather than gambling on that -- a rule with no
10009    /// PTY analogue, which is why PTY does not take this extra precondition.
10010    fn require_session_runtime_policy(
10011        &self,
10012        address: &SessionAddress,
10013        requirement: ProviderRuntimeRequirement,
10014    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10015        let runtime_policy = {
10016            let bindings = self
10017                .session_bindings
10018                .lock()
10019                .unwrap_or_else(|poisoned| poisoned.into_inner());
10020            let binding = bindings
10021                .get(&address.session.instance_id)
10022                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10023            if binding.workspace_id != address.workspace_id {
10024                return Err(failure(
10025                    NodeFailureCode::SessionWorkspaceMismatch,
10026                    "session belongs to another workspace",
10027                ));
10028            }
10029            if binding.generation != address.session.generation {
10030                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10031            }
10032            binding.runtime_policy
10033        };
10034        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10035            let snapshot = self.internal_session_snapshot(address)?;
10036            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10037                && snapshot.provider.lead_activity != ProviderActivity::Idle
10038            {
10039                return Err(failure(
10040                    NodeFailureCode::TurnInFlight,
10041                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10042                ));
10043            }
10044        }
10045        require_policy(runtime_policy, false, false, requirement)
10046            .map(|()| runtime_policy)
10047            .map_err(|_| failure(
10048                NodeFailureCode::UnsupportedCapability,
10049                "session runtime policy does not admit this semantic operation",
10050            ))
10051    }
10052
10053    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10054        let mut bindings = self
10055            .session_bindings
10056            .lock()
10057            .unwrap_or_else(|poisoned| poisoned.into_inner());
10058        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10059            binding.workspace_id == address.workspace_id
10060                && binding.generation == address.session.generation
10061        }) {
10062            let removed = bindings.remove(&address.session.instance_id);
10063            self.clear_terminal_frame_watermark(address);
10064            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10065            return removed;
10066        }
10067        None
10068    }
10069
10070    fn cleanup_session_owned_materialization(
10071        &self,
10072        address: &SessionAddress,
10073        binding: &SessionBinding,
10074    ) -> Result<(), NodeFailure> {
10075        let Some(id) = binding.materialization_id.as_ref() else {
10076            return Ok(());
10077        };
10078        let owner = self
10079            .materializations
10080            .lock()
10081            .unwrap_or_else(|poisoned| poisoned.into_inner())
10082            .get(id)
10083            .map(|record| record.owner().clone());
10084        match owner {
10085            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10086            Some(MaterializationOwner::Session {
10087                incarnation_id,
10088                instance_id,
10089                generation,
10090            }) if incarnation_id == self.incarnation_id
10091                && instance_id == address.session.instance_id
10092                && generation == address.session.generation =>
10093            {
10094                self.cleanup_materialization(id)
10095            }
10096            Some(MaterializationOwner::Session { .. }) => Err(failure(
10097                NodeFailureCode::BackendOperationFailed,
10098                "session environment ownership requires recovery",
10099            )),
10100        }
10101    }
10102
10103    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10104        let mut watermarks = self
10105            .terminal_frame_watermarks
10106            .lock()
10107            .unwrap_or_else(|poisoned| poisoned.into_inner());
10108        if watermarks
10109            .get(&address.session.instance_id)
10110            .is_some_and(|(current, _)| current == address)
10111        {
10112            watermarks.remove(&address.session.instance_id);
10113        }
10114    }
10115
10116    #[cfg(test)]
10117    fn address_for(
10118        &self,
10119        instance_id: AgentInstanceId,
10120        generation: SessionGeneration,
10121    ) -> Option<SessionAddress> {
10122        let binding = self.session_bindings
10123            .lock()
10124            .unwrap_or_else(|poisoned| poisoned.into_inner())
10125            .get(&instance_id)
10126            .cloned()?;
10127        (binding.generation == generation).then_some(SessionAddress {
10128            workspace_id: binding.workspace_id,
10129            session: SessionKey { instance_id, generation },
10130        })
10131    }
10132
10133    fn arm_resume(
10134        &self,
10135        address: &SessionAddress,
10136        command_id: CommandId,
10137        runtime_policy: ProviderRuntimePolicy,
10138    ) -> Result<(), NodeFailure> {
10139        let mut bindings = self
10140            .session_bindings
10141            .lock()
10142            .unwrap_or_else(|poisoned| poisoned.into_inner());
10143        let binding = bindings
10144            .get_mut(&address.session.instance_id)
10145            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10146        if binding.workspace_id != address.workspace_id {
10147            return Err(failure(
10148                NodeFailureCode::SessionWorkspaceMismatch,
10149                "session belongs to another workspace",
10150            ));
10151        }
10152        if binding.generation != address.session.generation {
10153            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10154        }
10155        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10156        Ok(())
10157    }
10158
10159    fn clear_armed_resume(&self, address: &SessionAddress) {
10160        let mut bindings = self
10161            .session_bindings
10162            .lock()
10163            .unwrap_or_else(|poisoned| poisoned.into_inner());
10164        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10165            if binding.workspace_id == address.workspace_id
10166                && binding.generation == address.session.generation
10167            {
10168                binding.pending_resume = None;
10169            }
10170        }
10171    }
10172
10173    fn publish_control(&self, event: ControlEvent) {
10174        let revokes_harness_mcp = matches!(
10175            &event.event,
10176            ControlEventKind::Exited { .. }
10177                | ControlEventKind::Failed { .. }
10178                | ControlEventKind::Removed
10179        );
10180        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10181            let mut bindings = self
10182                .session_bindings
10183                .lock()
10184                .unwrap_or_else(|poisoned| poisoned.into_inner());
10185            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10186                return;
10187            };
10188            let previous_address = SessionAddress {
10189                workspace_id: binding.workspace_id.clone(),
10190                session: SessionKey {
10191                    instance_id: event.instance_id,
10192                    generation: binding.generation,
10193                },
10194            };
10195            let mut resume_command_rejected = false;
10196            if binding.generation == event.generation {
10197                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10198                    let rejected_resume = matches!(
10199                        &event.event,
10200                        ControlEventKind::CommandRejected { .. }
10201                    ) && event.command_id == Some(pending_command_id);
10202                    let resume_failed = matches!(
10203                        &event.event,
10204                        ControlEventKind::ResumeDenied { .. }
10205                            | ControlEventKind::ResumeFailed { .. }
10206                    );
10207                    if pending_generation == binding.generation
10208                        && (rejected_resume || resume_failed)
10209                    {
10210                        resume_command_rejected = rejected_resume;
10211                        binding.pending_resume = None;
10212                    }
10213                }
10214            } else {
10215                let expected = binding.generation.0.checked_add(1);
10216                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10217                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10218                    generation == binding.generation
10219                })
10220                    || expected != Some(event.generation.0)
10221                    || !authorized
10222                {
10223                    return;
10224                }
10225                let (_, _, runtime_policy) = binding
10226                    .pending_resume
10227                    .expect("authorized resume retains its pending runtime policy");
10228                binding.generation = event.generation;
10229                binding.runtime_policy = runtime_policy;
10230                binding.pending_resume = None;
10231            }
10232            (
10233                SessionAddress {
10234                    workspace_id: binding.workspace_id.clone(),
10235                    session: SessionKey {
10236                        instance_id: event.instance_id,
10237                        generation: event.generation,
10238                    },
10239                },
10240                (previous_address.session.generation != event.generation)
10241                    .then_some(previous_address),
10242                binding.record_id.clone(),
10243                binding.runtime_policy.provider_session_identity,
10244                resume_command_rejected,
10245            )
10246        };
10247        if let Some(previous_address) = previous_address.as_ref() {
10248            self.clear_terminal_frame_watermark(previous_address);
10249        }
10250        if let Some(record_id) = record_id {
10251            self.reconcile_managed_record(
10252                &record_id,
10253                &address,
10254                &event,
10255                identity_admitted,
10256                resume_command_rejected,
10257            );
10258        }
10259        // Every state change a session's process lifecycle can reach gets a
10260        // line here: this is the single chokepoint every `ControlEventKind`
10261        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10262        // came from a fresh spawn or a resume. Per-message provider chatter
10263        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10264        // matched here -- that is exactly the per-frame noise this crate
10265        // family's level discipline forbids at INFO.
10266        match &event.event {
10267            ControlEventKind::Running { process_id } => {
10268                tracing::info!(
10269                    node_id = %self.node_id,
10270                    workspace_id = %address.workspace_id,
10271                    session = ?address.session,
10272                    pid = ?process_id,
10273                    "session process is running",
10274                );
10275            }
10276            ControlEventKind::Exited { exit_code, forced } => {
10277                tracing::info!(
10278                    node_id = %self.node_id,
10279                    workspace_id = %address.workspace_id,
10280                    session = ?address.session,
10281                    exit_code = ?exit_code,
10282                    forced = forced,
10283                    "session process exited",
10284                );
10285            }
10286            ControlEventKind::Failed { message } => {
10287                tracing::warn!(
10288                    node_id = %self.node_id,
10289                    workspace_id = %address.workspace_id,
10290                    session = ?address.session,
10291                    cause = %message,
10292                    "session failed",
10293                );
10294            }
10295            ControlEventKind::CommandRejected { message } => {
10296                tracing::warn!(
10297                    node_id = %self.node_id,
10298                    workspace_id = %address.workspace_id,
10299                    session = ?address.session,
10300                    cause = %message,
10301                    "session command rejected",
10302                );
10303            }
10304            ControlEventKind::Removed => {
10305                tracing::info!(
10306                    node_id = %self.node_id,
10307                    workspace_id = %address.workspace_id,
10308                    session = ?address.session,
10309                    "session removed",
10310                );
10311            }
10312            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10313                tracing::info!(
10314                    node_id = %self.node_id,
10315                    workspace_id = %address.workspace_id,
10316                    session = ?address.session,
10317                    provider_session = ?provider_session,
10318                    "session resume authorized",
10319                );
10320            }
10321            ControlEventKind::Resumed { session: provider_session, process_id } => {
10322                tracing::info!(
10323                    node_id = %self.node_id,
10324                    workspace_id = %address.workspace_id,
10325                    session = ?address.session,
10326                    provider_session = ?provider_session,
10327                    pid = ?process_id,
10328                    "session resumed",
10329                );
10330            }
10331            ControlEventKind::ResumeDenied { reason } => {
10332                tracing::warn!(
10333                    node_id = %self.node_id,
10334                    workspace_id = %address.workspace_id,
10335                    session = ?address.session,
10336                    cause = %reason,
10337                    "session resume denied",
10338                );
10339            }
10340            ControlEventKind::ResumeFailed { message } => {
10341                tracing::warn!(
10342                    node_id = %self.node_id,
10343                    workspace_id = %address.workspace_id,
10344                    session = ?address.session,
10345                    cause = %message,
10346                    "session resume failed",
10347                );
10348            }
10349            _ => {}
10350        }
10351        let stream_chunk = agent_stream_chunk(&event);
10352        self.publish(NodeEvent::Control {
10353            address: address.clone(),
10354            event,
10355        });
10356        if let Some(chunk) = stream_chunk {
10357            self.publish_agent_stream_chunk(address.clone(), chunk);
10358        }
10359        if revokes_harness_mcp {
10360            SessionLifecycleHook::on_session_ended(self, &address);
10361        }
10362    }
10363
10364    fn reconcile_managed_record(
10365        &self,
10366        record_id: &SessionRecordId,
10367        address: &SessionAddress,
10368        event: &ControlEvent,
10369        identity_admitted: bool,
10370        resume_command_rejected: bool,
10371    ) {
10372        let _transaction = self
10373            .state_transaction
10374            .lock()
10375            .unwrap_or_else(|poisoned| poisoned.into_inner());
10376        let observed_identity = match &event.event {
10377            ControlEventKind::ProviderEvent {
10378                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10379                ..
10380            } if identity_admitted => Some(identity.clone()),
10381            _ => None,
10382        };
10383        let identity_was_observed = observed_identity.is_some();
10384        let replacement_id = observed_identity
10385            .as_ref()
10386            .and_then(|_| self.allocate_record_id().ok());
10387        let mut upserts = Vec::new();
10388        let mut removals = Vec::new();
10389        let mut rebind = None;
10390        let original_records;
10391        let original_materializations = self
10392            .materializations
10393            .lock()
10394            .unwrap_or_else(|poisoned| poisoned.into_inner())
10395            .clone();
10396        {
10397            let mut records = self
10398                .session_records
10399                .lock()
10400                .unwrap_or_else(|poisoned| poisoned.into_inner());
10401            original_records = records.records.clone();
10402            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10403                return;
10404            };
10405            if let Some(identity) = observed_identity {
10406                let existing_id = records
10407                    .records
10408                    .values()
10409                    .find(|record| {
10410                        record.record_id != *record_id
10411                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10412                                session_registry::same_provider_session(
10413                                    &record.provider,
10414                                    record_identity,
10415                                    &current_snapshot.provider,
10416                                    &identity,
10417                                )
10418                            })
10419                    })
10420                    .map(|record| record.record_id.clone());
10421                if let Some(existing_id) = existing_id {
10422                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10423                        record.mode == current_snapshot.mode
10424                            && record.workspace_id == current_snapshot.workspace_id
10425                            && record.environment_profile == current_snapshot.environment_profile
10426                            && record.bundle == current_snapshot.bundle
10427                            && record.context_id == current_snapshot.context_id
10428                            && record.context == current_snapshot.context
10429                            && record
10430                                .canonical_root
10431                                .as_utf8()
10432                                .zip(current_snapshot.canonical_root.as_utf8())
10433                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10434                    });
10435                    if !scope_matches {
10436                        if let Some(current) = records.records.get_mut(record_id) {
10437                            current.state = ManagedSessionState::Unavailable;
10438                            current.last_error =
10439                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10440                            current.updated_at_unix_ms = unix_time_ms();
10441                            upserts.push(current.clone());
10442                        }
10443                    } else {
10444                        let existing_is_active = records
10445                            .records
10446                            .get(&existing_id)
10447                            .is_some_and(|record| record.active_session.is_some());
10448                        if existing_is_active {
10449                            if let Some(current) = records.records.get_mut(record_id) {
10450                                current.state = ManagedSessionState::Unavailable;
10451                                current.last_error =
10452                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10453                                current.updated_at_unix_ms = unix_time_ms();
10454                                upserts.push(current.clone());
10455                            }
10456                        } else {
10457                            if current_snapshot.provider_session.is_some() {
10458                                if let Some(current) = records.records.get_mut(record_id) {
10459                                    current.active_session = None;
10460                                    current.state = ManagedSessionState::Dormant;
10461                                    current.updated_at_unix_ms = unix_time_ms();
10462                                    upserts.push(current.clone());
10463                                }
10464                            } else {
10465                                records.records.remove(record_id);
10466                                removals.push(record_id.clone());
10467                            }
10468                            if let Some(existing) = records.records.get_mut(&existing_id) {
10469                                existing.provider_session = Some(identity.clone());
10470                                existing.active_session = Some(address.clone());
10471                                existing.state = ManagedSessionState::Live;
10472                                existing.updated_at_unix_ms = unix_time_ms();
10473                                existing.last_error = None;
10474                                upserts.push(existing.clone());
10475                            }
10476                            rebind = Some(existing_id);
10477                        }
10478                    }
10479                } else if current_snapshot
10480                    .provider_session
10481                    .as_ref()
10482                    .is_some_and(|current| {
10483                        !session_registry::same_provider_session(
10484                            &current_snapshot.provider,
10485                            current,
10486                            &current_snapshot.provider,
10487                            &identity,
10488                        )
10489                    })
10490                {
10491                    if let Some(current) = records.records.get_mut(record_id) {
10492                        current.active_session = None;
10493                        if current.bundle.is_some() || current.context.is_some() {
10494                            let had_bundle = current.bundle.is_some();
10495                            current.provider_session = None;
10496                            current.bundle = None;
10497                            current.context_id = None;
10498                            current.context = None;
10499                            current.state = ManagedSessionState::Unavailable;
10500                            current.last_error = Some(if had_bundle {
10501                                "bundle-unavailable".to_owned()
10502                            } else {
10503                                "context-unavailable".to_owned()
10504                            });
10505                        } else {
10506                            current.state = ManagedSessionState::Dormant;
10507                        }
10508                        current.updated_at_unix_ms = unix_time_ms();
10509                        upserts.push(current.clone());
10510                    }
10511                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10512                        if let Some(current) = records.records.get_mut(record_id) {
10513                            current.state = ManagedSessionState::Unavailable;
10514                            current.last_error =
10515                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10516                            current.updated_at_unix_ms = unix_time_ms();
10517                            upserts.push(current.clone());
10518                        }
10519                    } else if let Some(new_record_id) = replacement_id {
10520                        let now = unix_time_ms();
10521                        let replacement = ManagedSessionRecord {
10522                            record_id: new_record_id.clone(),
10523                            display_name: format!(
10524                                "{} #{}",
10525                                current_snapshot.provider.as_str(),
10526                                records
10527                                    .records
10528                                    .values()
10529                                    .filter(|record| {
10530                                        record.provider == current_snapshot.provider
10531                                    })
10532                                    .count()
10533                                    .saturating_add(1),
10534                            ),
10535                            provider: current_snapshot.provider.clone(),
10536                            mode: current_snapshot.mode,
10537                            state: ManagedSessionState::Live,
10538                            workspace_id: current_snapshot.workspace_id,
10539                            canonical_root: current_snapshot.canonical_root,
10540                            provider_session: Some(identity),
10541                            active_session: Some(address.clone()),
10542                            environment_profile: current_snapshot.environment_profile,
10543                            bundle: current_snapshot.bundle,
10544                            context_id: current_snapshot.context_id,
10545                            context: current_snapshot.context,
10546                            // This is a fresh record_id representing the session under its
10547                            // newly-observed identity; it has not yet produced any clean
10548                            // exit of its own, so it starts with no exported pack (the old
10549                            // record, still addressable under its own record_id, keeps
10550                            // whatever exported_context it already had).
10551                            exported_context: None,
10552                            task_binding: current_snapshot.task_binding,
10553                            created_at_unix_ms: now,
10554                            updated_at_unix_ms: now,
10555                            last_error: None,
10556                        };
10557                        records
10558                            .records
10559                            .insert(new_record_id.clone(), replacement.clone());
10560                        upserts.push(replacement);
10561                        rebind = Some(new_record_id);
10562                    } else if let Some(current) = records.records.get_mut(record_id) {
10563                        current.state = ManagedSessionState::Unavailable;
10564                        current.last_error =
10565                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10566                        upserts.push(current.clone());
10567                    }
10568                } else if let Some(current) = records.records.get_mut(record_id) {
10569                    current.provider_session = Some(identity);
10570                    current.active_session = Some(address.clone());
10571                    current.state = ManagedSessionState::Live;
10572                    current.updated_at_unix_ms = unix_time_ms();
10573                    current.last_error = None;
10574                    upserts.push(current.clone());
10575                }
10576            } else if let Some(current) = records.records.get_mut(record_id) {
10577                if resume_command_rejected {
10578                    current.active_session = None;
10579                    current.state = detached_record_state(current);
10580                    current.updated_at_unix_ms = unix_time_ms();
10581                    current.last_error =
10582                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10583                    upserts.push(current.clone());
10584                } else {
10585                    match &event.event {
10586                    ControlEventKind::Running { .. }
10587                    | ControlEventKind::ResumeAuthorized { .. }
10588                    | ControlEventKind::Resumed { .. } => {
10589                        current.active_session = Some(address.clone());
10590                        current.state = active_record_state(
10591                            identity_admitted,
10592                            current.provider_session.is_some(),
10593                        );
10594                        current.updated_at_unix_ms = unix_time_ms();
10595                        current.last_error = None;
10596                        upserts.push(current.clone());
10597                    }
10598                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10599                        current.active_session = None;
10600                        current.state = detached_record_state(current);
10601                        current.updated_at_unix_ms = unix_time_ms();
10602                        upserts.push(current.clone());
10603                    }
10604                    ControlEventKind::Failed { message }
10605                    | ControlEventKind::ResumeFailed { message } => {
10606                        current.active_session = None;
10607                        current.state = detached_record_state(current);
10608                        current.updated_at_unix_ms = unix_time_ms();
10609                        current.last_error = Some(
10610                            session_registry::sanitized_record_error_summary(message),
10611                        );
10612                        upserts.push(current.clone());
10613                    }
10614                    ControlEventKind::ResumeDenied { reason } => {
10615                        current.active_session = None;
10616                        current.state = detached_record_state(current);
10617                        current.updated_at_unix_ms = unix_time_ms();
10618                        current.last_error = Some(
10619                            session_registry::sanitized_record_error_summary(reason),
10620                        );
10621                        upserts.push(current.clone());
10622                    }
10623                        _ => {}
10624                    }
10625                }
10626            }
10627        }
10628        let materialization_owner_record = identity_was_observed.then(|| {
10629            rebind.clone().unwrap_or_else(|| record_id.clone())
10630        }).and_then(|candidate| {
10631            self.session_records
10632                .lock()
10633                .unwrap_or_else(|poisoned| poisoned.into_inner())
10634                .records
10635                .get(&candidate)
10636                .filter(|record| {
10637                    record.provider_session.is_some()
10638                        && record.active_session.as_ref() == Some(address)
10639                })
10640                .map(|_| candidate)
10641        });
10642        if let Some(owner_record_id) = materialization_owner_record {
10643            let materialization_id = self
10644                .session_bindings
10645                .lock()
10646                .unwrap_or_else(|poisoned| poisoned.into_inner())
10647                .get(&address.session.instance_id)
10648                .and_then(|binding| binding.materialization_id.clone());
10649            if let Some(materialization_id) = materialization_id {
10650                let mut transfer_failed = false;
10651                {
10652                    let mut materializations = self
10653                        .materializations
10654                        .lock()
10655                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10656                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10657                        match ownership.owner() {
10658                            MaterializationOwner::Session {
10659                                incarnation_id,
10660                                instance_id,
10661                                generation,
10662                            } if *incarnation_id == self.incarnation_id
10663                                && *instance_id == address.session.instance_id
10664                                && *generation == address.session.generation =>
10665                            {
10666                                transfer_failed = ownership
10667                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10668                                    .is_err();
10669                            }
10670                            MaterializationOwner::Record { record_id }
10671                                if record_id == &owner_record_id => {}
10672                            MaterializationOwner::Record { record_id } => {
10673                                let expected = record_id.clone();
10674                                transfer_failed = ownership
10675                                    .transfer_record_owner(
10676                                        &expected,
10677                                        owner_record_id.clone(),
10678                                        unix_time_ms(),
10679                                    )
10680                                    .is_err();
10681                            }
10682                            _ => transfer_failed = true,
10683                        }
10684                        if transfer_failed {
10685                            let _ = ownership.mark_recovery_required(unix_time_ms());
10686                        }
10687                    } else {
10688                        transfer_failed = true;
10689                    }
10690                }
10691                if transfer_failed {
10692                    if let Some(record) = self
10693                        .session_records
10694                        .lock()
10695                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10696                        .records
10697                        .get_mut(&owner_record_id)
10698                    {
10699                        record.state = ManagedSessionState::Unavailable;
10700                        record.last_error = Some("environment-profile-unavailable".to_owned());
10701                        record.updated_at_unix_ms = unix_time_ms();
10702                        upserts.push(record.clone());
10703                    }
10704                }
10705            }
10706        }
10707        if let Some(new_record_id) = rebind.clone() {
10708            if let Some(binding) = self
10709                .session_bindings
10710                .lock()
10711                .unwrap_or_else(|poisoned| poisoned.into_inner())
10712                .get_mut(&address.session.instance_id)
10713            {
10714                binding.record_id = Some(new_record_id);
10715            }
10716        }
10717        if upserts.is_empty() && removals.is_empty() {
10718            return;
10719        }
10720        if self.persist_state_locked().is_err() {
10721            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10722            let mut records = self
10723                .session_records
10724                .lock()
10725                .unwrap_or_else(|poisoned| poisoned.into_inner());
10726            records.records = original_records;
10727            *self
10728                .materializations
10729                .lock()
10730                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10731            if let Some(current) = records.records.get_mut(record_id) {
10732                current.state = ManagedSessionState::Unavailable;
10733                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10734                current.updated_at_unix_ms = unix_time_ms();
10735                upserts = vec![current.clone()];
10736            } else {
10737                upserts.clear();
10738            }
10739            removals.clear();
10740            drop(records);
10741            if let Some(binding) = self
10742                .session_bindings
10743                .lock()
10744                .unwrap_or_else(|poisoned| poisoned.into_inner())
10745                .get_mut(&address.session.instance_id)
10746            {
10747                binding.record_id = Some(record_id.clone());
10748            }
10749            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10750        }
10751        for record in upserts {
10752            self.publish_record(record);
10753        }
10754        for record_id in removals {
10755            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10756        }
10757    }
10758
10759    fn snapshot(&self) -> NodeSnapshot {
10760        let control = self.handle.snapshot();
10761        let bindings = self
10762            .session_bindings
10763            .lock()
10764            .unwrap_or_else(|poisoned| poisoned.into_inner())
10765            .clone();
10766        let workspace_roots = self
10767            .workspaces
10768            .read()
10769            .unwrap_or_else(|poisoned| poisoned.into_inner())
10770            .clone();
10771        let environment_profiles = self
10772            .environment_profiles
10773            .read()
10774            .unwrap_or_else(|poisoned| poisoned.into_inner());
10775        let mut workspaces = workspace_roots
10776            .iter()
10777            .map(|(workspace_id, canonical_root)| {
10778                (
10779                    workspace_id.clone(),
10780                    WorkspaceSnapshot {
10781                        workspace_id: workspace_id.clone(),
10782                        canonical_root: opaque_windows_path(canonical_root.clone()),
10783                        sessions: Vec::new(),
10784                        worktree_service_mode: Some(
10785                            self.worktree_service_modes
10786                                .get(workspace_id)
10787                                .copied()
10788                                .unwrap_or(WorktreeServiceMode::Manual),
10789                        ),
10790                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10791                            profiles: self
10792                                .managed_worktree_profiles
10793                                .get(workspace_id)
10794                                .into_iter()
10795                                .flat_map(BTreeMap::values)
10796                                .map(|profile| ManagedWorktreeProfileSummary {
10797                                    id: profile.profile_id().clone(),
10798                                    revision: profile.revision().clone(),
10799                                    retention: profile.retention(),
10800                                })
10801                                .collect(),
10802                        }),
10803                    },
10804                )
10805            })
10806            .collect::<BTreeMap<_, _>>();
10807        let mut agent_progress = Vec::new();
10808        for session in &control.sessions {
10809            let Some(binding) = bindings.get(&session.instance_id) else {
10810                continue;
10811            };
10812            if binding.generation != session.generation {
10813                continue;
10814            }
10815            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10816                let address = SessionAddress {
10817                    workspace_id: binding.workspace_id.clone(),
10818                    session: SessionKey {
10819                        instance_id: session.instance_id,
10820                        generation: session.generation,
10821                    },
10822                };
10823                if let Some(progress) =
10824                    agent_progress_from_provider_snapshot(address, &session.provider)
10825                {
10826                    agent_progress.push(progress);
10827                }
10828                workspace.sessions.push(session.clone());
10829            }
10830        }
10831        NodeSnapshot {
10832            node_id: self.node_id.clone(),
10833            enabled_providers: self.enabled_providers.clone(),
10834            provider_runtime_statuses: {
10835                let updates = self
10836                    .provider_runtime_status_updates
10837                    .read()
10838                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10839                ProviderRuntimeStatuses::new(
10840                    self.provider_runtime_statuses
10841                        .iter()
10842                        .filter(|status| !updates.contains_key(status.provider()))
10843                        .cloned()
10844                        .chain(updates.values().cloned()),
10845                )
10846                .expect("refreshed provider runtime inventory remains bounded and unique")
10847            },
10848            workspaces: workspaces.into_values().collect(),
10849            session_records: self
10850                .session_records
10851                .lock()
10852                .unwrap_or_else(|poisoned| poisoned.into_inner())
10853                .records
10854                .values()
10855                .cloned()
10856                .collect(),
10857            managed_worktrees: self.managed_worktrees
10858                .lock()
10859                .unwrap_or_else(|poisoned| poisoned.into_inner())
10860                .snapshots(),
10861            launch_inventory: Some(LaunchInventory {
10862                spawn_profiles: Some(
10863                    self.spawn_profiles
10864                        .iter()
10865                        .filter_map(|profile| {
10866                            resolved_spawn_profile_summary(profile, &environment_profiles)
10867                        })
10868                        .collect(),
10869                ),
10870                bundles: Some(
10871                    self.bundle_catalog
10872                        .read()
10873                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10874                        .iter()
10875                        .map(NodeBundle::receipt)
10876                        .collect(),
10877                ),
10878                // Opaque station allowlist ids from empty-default catalog /
10879                // optional startup loader. Never secrets. Dig2 probe stays stub.
10880                network_allowlists: {
10881                    let catalog = self
10882                        .network_allowlist_catalog
10883                        .read()
10884                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10885                    if catalog.is_empty() {
10886                        None
10887                    } else {
10888                        Some(catalog.ids().cloned().collect())
10889                    }
10890                },
10891            }),
10892            agent_progress,
10893        }
10894    }
10895
10896    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10897        assert!(
10898            !matches!(
10899                &event,
10900                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10901            ),
10902            "terminal frame and agent stream events must use their own dedicated live channel",
10903        );
10904        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10905        let sequence = history
10906            .last_sequence
10907            .checked_add(1)
10908            .expect("node event sequence exhausted");
10909        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10910            if let Some(evicted) = history.events.pop_front() {
10911                history.replay_floor_sequence = evicted
10912                    .sequence
10913                    .checked_add(1)
10914                    .expect("node replay floor sequence exhausted");
10915                history.removed_record_providers.remove(&evicted.sequence);
10916            }
10917        }
10918        match &event {
10919            NodeEvent::HarnessMcpReadCall { .. } => {
10920                panic!("transient harness MCP calls must not enter durable event history")
10921            }
10922            NodeEvent::SessionRecordUpserted { record } => {
10923                history
10924                    .record_providers
10925                    .insert(record.record_id.clone(), record.provider.clone());
10926            }
10927            NodeEvent::SessionRecordRemoved { record_id } => {
10928                if let Some(provider) = history.record_providers.remove(record_id) {
10929                    history.removed_record_providers.insert(sequence, provider);
10930                }
10931            }
10932            NodeEvent::ManagedWorktreeUpserted { .. }
10933            | NodeEvent::ManagedWorktreeRemoved { .. }
10934            | NodeEvent::Control { .. }
10935            | NodeEvent::ControllerChanged { .. }
10936            | NodeEvent::SessionRecordHistorySummarized { .. }
10937            | NodeEvent::WorkspaceAdded { .. }
10938            | NodeEvent::WorkspaceRemoved { .. }
10939            | NodeEvent::ResyncRequired { .. }
10940            | NodeEvent::AgentStream { .. }
10941            | NodeEvent::TerminalFrame { .. } => {}
10942        }
10943        let envelope = NodeEventEnvelope { sequence, event };
10944        history.events.push_back(envelope.clone());
10945        history.last_sequence = sequence;
10946        let _ = self.event_tx.send(envelope.clone());
10947        drop(history);
10948        envelope
10949    }
10950
10951    /// Publishes a transient (never durable) `NodeEvent` -- today only
10952    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10953    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10954    /// why this must never ride the shared `event_tx` bus). Returns whether
10955    /// the send reached at least one subscriber: `broadcast::Sender::send`
10956    /// fails in exactly one way, no active receiver at that instant, and
10957    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10958    /// return to fail the pending call immediately rather than leave it to
10959    /// expire silently at `LOCAL_READ_TIMEOUT`/
10960    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10961    fn publish_transient(&self, event: NodeEvent) -> bool {
10962        assert!(event.requires_harness_mcp_proxy_capability());
10963        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10964        let envelope = NodeEventEnvelope { sequence: 0, event };
10965        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10966            Ok(_) => true,
10967            Err(broadcast::error::SendError(undelivered)) => {
10968                if let Some(NodeEventEnvelope {
10969                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10970                    ..
10971                }) = undelivered.first() {
10972                    // `request.content_type` only, never a per-tool label --
10973                    // this crate does not decode `request.body` and does not
10974                    // hold `hatchery-harness-api` (Nested Control Plane
10975                    // doctrine, Law 3; see this crate's dependency on
10976                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10977                    tracing::warn!(
10978                        call_id = call_id.as_str(),
10979                        content_type = ?request.content_type,
10980                        cause = "no-subscribers",
10981                        "harness MCP proxy event could not be published",
10982                    );
10983                }
10984                false
10985            }
10986        }
10987    }
10988
10989    fn publish_terminal_frames(&self) {
10990        let snapshot = self.handle.snapshot();
10991        let bindings = self
10992            .session_bindings
10993            .lock()
10994            .unwrap_or_else(|poisoned| poisoned.into_inner());
10995        let candidates = snapshot
10996            .sessions
10997            .iter()
10998            .filter_map(|session| {
10999                let binding = bindings.get(&session.instance_id)?;
11000                if binding.generation != session.generation {
11001                    return None;
11002                }
11003                let frame = session.terminal_frame.clone()?;
11004                Some((
11005                    SessionAddress {
11006                        workspace_id: binding.workspace_id.clone(),
11007                        session: SessionKey {
11008                            instance_id: session.instance_id,
11009                            generation: session.generation,
11010                        },
11011                    },
11012                    frame,
11013                ))
11014            })
11015            .collect();
11016        drop(bindings);
11017        self.publish_terminal_frame_candidates(candidates);
11018    }
11019
11020    fn publish_terminal_frame_candidates(
11021        &self,
11022        candidates: Vec<(SessionAddress, TerminalFrame)>,
11023    ) {
11024        let mut watermarks = self
11025            .terminal_frame_watermarks
11026            .lock()
11027            .unwrap_or_else(|poisoned| poisoned.into_inner());
11028        let mut events = Vec::with_capacity(candidates.len());
11029        for (address, frame) in candidates {
11030            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11031            let advanced = watermarks
11032                .get(&address.session.instance_id)
11033                .map_or(true, |(current_address, sequence)| {
11034                    current_address != &address || frame.sequence > *sequence
11035                });
11036            if !advanced {
11037                continue;
11038            }
11039            if is_first_frame {
11040                // Do NOT sessionize on first PTY output: a session is a
11041                // provider conversation id, not bytes on the wire. Drafts
11042                // leave the provisional map only when HQ attaches
11043                // (Resize / TerminalBytes). The attach-never-arrived timer
11044                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11045                // disconnect grace starts only on a real client WS close.
11046                // This is the first PTY output this node has observed for
11047                // this session -- a one-time event per spawn, not per-frame
11048                // chatter (the watermark makes sure of that). A session that
11049                // reaches this line with no controller attached anywhere on
11050                // the node is running unobserved: exactly how processes from
11051                // repeated spawn attempts went orphaned for hours without a
11052                // single line about it.
11053                if self.controller_state().is_none() {
11054                    tracing::warn!(
11055                        node_id = %self.node_id,
11056                        workspace_id = %address.workspace_id,
11057                        session = ?address.session,
11058                        terminal_size = ?frame.size,
11059                        "session producing output with no controller attached",
11060                    );
11061                } else {
11062                    tracing::info!(
11063                        node_id = %self.node_id,
11064                        workspace_id = %address.workspace_id,
11065                        session = ?address.session,
11066                        terminal_size = ?frame.size,
11067                        "session produced its first output",
11068                    );
11069                }
11070            }
11071            watermarks.insert(
11072                address.session.instance_id,
11073                (address.clone(), frame.sequence),
11074            );
11075            if let Some((at, reported)) = self
11076                .pty_input_marks
11077                .lock()
11078                .unwrap_or_else(|poisoned| poisoned.into_inner())
11079                .get_mut(&address.session.instance_id)
11080            {
11081                // First frame after an input: PTY write -> output published.
11082                if !*reported && at.elapsed() < Duration::from_secs(5) {
11083                    *reported = true;
11084                    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");
11085                }
11086            }
11087            events.push(NodeEvent::TerminalFrame { address, frame });
11088        }
11089        drop(watermarks);
11090        if events.is_empty() {
11091            return;
11092        }
11093        let mut history = self
11094            .history
11095            .lock()
11096            .unwrap_or_else(|poisoned| poisoned.into_inner());
11097        let mut envelopes = Vec::with_capacity(events.len());
11098        for event in events {
11099            let sequence = history
11100                .last_sequence
11101                .checked_add(1)
11102                .expect("node event sequence exhausted");
11103            history.last_sequence = sequence;
11104            envelopes.push(NodeEventEnvelope { sequence, event });
11105        }
11106        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11107        drop(history);
11108    }
11109
11110    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11111    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11112    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11113    /// counter every other event kind shares, never a counter of its own --
11114    /// `queue_connection_event`/`queue_connection_event_batch` compare
11115    /// `envelope.sequence` against one `discard_events_through` watermark
11116    /// regardless of which broadcast channel it arrived on, so a
11117    /// per-channel counter would desynchronize that comparison.
11118    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11119        let mut history = self
11120            .history
11121            .lock()
11122            .unwrap_or_else(|poisoned| poisoned.into_inner());
11123        let sequence = history
11124            .last_sequence
11125            .checked_add(1)
11126            .expect("node event sequence exhausted");
11127        history.last_sequence = sequence;
11128        drop(history);
11129        // Debug, not info/warn -- this fires on every single chunk, but it
11130        // is exactly the counter the next drop-measurement pass needs: what
11131        // the node actually emitted (`source_sequence`, `kind`) versus what
11132        // a harness subscriber's own forwarder later logs having received
11133        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11134        // event"), so a gap between the two logs names itself instead of
11135        // needing a new counter added ad hoc.
11136        tracing::debug!(
11137            source_sequence = chunk.source_sequence,
11138            kind = agent_stream_chunk_kind_label(&chunk.kind),
11139            envelope_sequence = sequence,
11140            "publishing agent stream chunk",
11141        );
11142        let envelope = NodeEventEnvelope {
11143            sequence,
11144            event: NodeEvent::AgentStream { address, chunk },
11145        };
11146        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11147    }
11148
11149    fn current_sequence(&self) -> u64 {
11150        self.history
11151            .lock()
11152            .unwrap_or_else(|poisoned| poisoned.into_inner())
11153            .last_sequence
11154    }
11155
11156    fn controller_state(&self) -> Option<ControllerState> {
11157        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11158        let lease = controller.as_ref().copied()?;
11159        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11160        if remaining.is_zero() {
11161            *controller = None;
11162            return None;
11163        }
11164        Some(ControllerState {
11165            connection_id: lease.connection_id,
11166            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11167        })
11168    }
11169
11170    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11171        if role != ClientRole::Operator {
11172            tracing::warn!(
11173                node_id = %self.node_id,
11174                connection_id,
11175                "controller lease rejected: observer connections cannot acquire control",
11176            );
11177            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11178        }
11179        let lease_ms = if lease_ms == 0 {
11180            DEFAULT_CONTROLLER_LEASE_MS
11181        } else {
11182            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11183        };
11184        let now = Instant::now();
11185        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11186        if let Some(active) = controller.as_ref() {
11187            if active.connection_id != connection_id && active.expires_at > now {
11188                tracing::warn!(
11189                    node_id = %self.node_id,
11190                    connection_id,
11191                    holder_connection_id = active.connection_id,
11192                    "controller lease rejected: another operator holds it",
11193                );
11194                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11195            }
11196        }
11197        // A reconnect (this connection already holds the lease, renewing
11198        // before expiry) happens on a periodic cadence; logging it at INFO
11199        // every renewal would be exactly the heartbeat chatter this crate
11200        // family's level discipline forbids. A genuine attach -- the
11201        // controller identity actually changing -- is a state change and
11202        // gets INFO; a same-connection renewal gets DEBUG.
11203        let is_new_attach = match controller.as_ref() {
11204            Some(active) => active.connection_id != connection_id,
11205            None => true,
11206        };
11207        *controller = Some(ControllerLease {
11208            connection_id,
11209            expires_at: now + Duration::from_millis(lease_ms),
11210        });
11211        drop(controller);
11212        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11213        if is_new_attach {
11214            tracing::info!(
11215                node_id = %self.node_id,
11216                connection_id,
11217                lease_ms,
11218                "controller lease acquired",
11219            );
11220        } else {
11221            tracing::debug!(
11222                node_id = %self.node_id,
11223                connection_id,
11224                lease_ms,
11225                "controller lease renewed",
11226            );
11227        }
11228        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11229        Ok(state)
11230    }
11231
11232    fn release_controller(&self, connection_id: u64) -> bool {
11233        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11234        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11235        if released {
11236            *controller = None;
11237        }
11238        drop(controller);
11239        if released {
11240            tracing::info!(
11241                node_id = %self.node_id,
11242                connection_id,
11243                "controller lease released",
11244            );
11245            self.publish(NodeEvent::ControllerChanged { controller: None });
11246        }
11247        released
11248    }
11249
11250    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11251    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11252    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11253    /// provider session; it only cancels the orphan timer. Disconnect/reload
11254    /// draft kills are owned by HQ and arm only on a real client WS close.
11255    fn mark_spawn_provisional(&self, address: SessionAddress) {
11256        let id = address.session.instance_id;
11257        self.provisional_spawns
11258            .lock()
11259            .unwrap_or_else(|poisoned| poisoned.into_inner())
11260            .insert(id, (address, Instant::now()));
11261        tracing::info!(
11262            node_id = %self.node_id,
11263            instance_id = id.0,
11264            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11265            "draft spawn; waiting for HQ view",
11266        );
11267    }
11268
11269    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11270        let removed = self
11271            .provisional_spawns
11272            .lock()
11273            .unwrap_or_else(|poisoned| poisoned.into_inner())
11274            .remove(&instance_id);
11275        if removed.is_some() {
11276            tracing::info!(
11277                node_id = %self.node_id,
11278                instance_id = instance_id.0,
11279                "draft spawn claimed by HQ view",
11280            );
11281        }
11282    }
11283
11284    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11285        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11286        let mut map = self
11287            .provisional_spawns
11288            .lock()
11289            .unwrap_or_else(|poisoned| poisoned.into_inner());
11290        let due: Vec<AgentInstanceId> = map
11291            .iter()
11292            .filter(|(_, (_, at))| at.elapsed() >= grace)
11293            .map(|(id, _)| *id)
11294            .collect();
11295        due.into_iter()
11296            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11297            .collect()
11298    }
11299
11300    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11301        let instance_id = address.session.instance_id;
11302        tracing::warn!(
11303            node_id = %self.node_id,
11304            instance_id = instance_id.0,
11305            workspace = %address.workspace_id,
11306            "draft spawn never viewed; stopping PTY",
11307        );
11308        let _ = self.dispatch(ControlCommand::Stop {
11309            instance_id,
11310            force: true,
11311        });
11312        let _ = self.remove_session(&address).await;
11313    }
11314
11315    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11316        if role != ClientRole::Operator {
11317            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11318        }
11319        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11320        let now = Instant::now();
11321        match controller.as_ref().copied() {
11322            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11323            Some(active) if active.expires_at <= now => {
11324                *controller = None;
11325                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11326            }
11327            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11328        }
11329    }
11330
11331    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11332        if !self
11333            .workspaces
11334            .read()
11335            .unwrap_or_else(|poisoned| poisoned.into_inner())
11336            .contains_key(&address.workspace_id)
11337        {
11338            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11339        }
11340        let binding = self
11341            .session_bindings
11342            .lock()
11343            .unwrap_or_else(|poisoned| poisoned.into_inner())
11344            .get(&address.session.instance_id)
11345            .cloned()
11346            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11347        if binding.workspace_id != address.workspace_id {
11348            return Err(failure(
11349                NodeFailureCode::SessionWorkspaceMismatch,
11350                "session belongs to another workspace",
11351            ));
11352        }
11353        if binding.generation != address.session.generation {
11354            return Err(failure(
11355                NodeFailureCode::StaleGeneration,
11356                &format!(
11357                    "stale session generation: expected {}, received {}",
11358                    binding.generation.0,
11359                    address.session.generation.0,
11360                ),
11361            ));
11362        }
11363        let snapshot = self.handle.snapshot();
11364        let Some(current) = snapshot
11365            .sessions
11366            .iter()
11367            .find(|item| item.instance_id == address.session.instance_id)
11368        else {
11369            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11370        };
11371        if current.generation != address.session.generation {
11372            return Err(failure(
11373                NodeFailureCode::StaleGeneration,
11374                &format!(
11375                    "stale session generation: expected {}, received {}",
11376                    current.generation.0,
11377                    address.session.generation.0,
11378                ),
11379            ));
11380        }
11381        Ok(current.agent_id.clone())
11382    }
11383
11384    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11385        self.dispatch_envelope(self.prepare_command(command))
11386    }
11387
11388    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11389        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11390        CommandEnvelope {
11391            id: CommandId(id),
11392            command,
11393        }
11394    }
11395
11396    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11397        let command_id = envelope.id;
11398        self.handle
11399            .dispatch(envelope)
11400            .map_err(|error| match error {
11401                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11402                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11403            })?;
11404        Ok(command_id)
11405    }
11406
11407    /// Records the kernel's own reason for asynchronously refusing
11408    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11409    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11410    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11411    /// first, and if the map is still at capacity after that, the single
11412    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11413    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11414    /// is always the oldest.
11415    fn record_rejected_command(
11416        &self,
11417        command_id: CommandId,
11418        message: String,
11419        unsupported_transport: bool,
11420    ) {
11421        let now = Instant::now();
11422        let mut reasons = self.rejected_command_reasons.lock()
11423            .unwrap_or_else(|poisoned| poisoned.into_inner());
11424        reasons.retain(|_, reason| reason.expires_at > now);
11425        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11426            if let Some(&oldest) = reasons.keys().next() {
11427                reasons.remove(&oldest);
11428            }
11429        }
11430        reasons.insert(
11431            command_id,
11432            RejectedCommandReason {
11433                message,
11434                unsupported_transport,
11435                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11436            },
11437        );
11438    }
11439
11440    /// Removes and returns the kernel's reason for refusing `command_id`,
11441    /// if the drive loop has recorded one and it has not expired -- see
11442    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11443    /// wait loop calls this instead of blindly polling out its whole
11444    /// deadline when the command it is waiting on turns out to have been
11445    /// refused rather than merely slow.
11446    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11447        let now = Instant::now();
11448        let mut reasons = self.rejected_command_reasons.lock()
11449            .unwrap_or_else(|poisoned| poisoned.into_inner());
11450        match reasons.remove(&command_id) {
11451            Some(reason) if reason.expires_at > now => Some(reason),
11452            _ => None,
11453        }
11454    }
11455
11456    async fn dispatch_bounded(
11457        &self,
11458        command: ControlCommand,
11459        timeout_duration: Duration,
11460    ) -> Result<CommandId, NodeFailure> {
11461        let deadline = Instant::now() + timeout_duration;
11462        loop {
11463            match self.dispatch(command.clone()) {
11464                Ok(command_id) => return Ok(command_id),
11465                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11466                    sleep(Duration::from_millis(2)).await;
11467                }
11468                Err(error) => return Err(error),
11469            }
11470        }
11471    }
11472
11473    fn internal_session_snapshot(
11474        &self,
11475        address: &SessionAddress,
11476    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11477        self.validate_address(address)?;
11478        self.handle
11479            .snapshot()
11480            .sessions
11481            .clone()
11482            .into_iter()
11483            .find(|session| {
11484                session.instance_id == address.session.instance_id
11485                    && session.generation == address.session.generation
11486            })
11487            .ok_or_else(|| {
11488                failure(
11489                    NodeFailureCode::UnknownSession,
11490                    "session snapshot is unavailable",
11491                )
11492            })
11493    }
11494
11495    /// Confirms `address` names a live session spawned over the ACP
11496    /// transport and returns its current snapshot -- the gate every
11497    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11498    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11499    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11500    /// the session's own recorded `TransportKind` does, and that lives on
11501    /// the live snapshot, not the binding.
11502    fn require_acp_session(
11503        &self,
11504        address: &SessionAddress,
11505    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11506        let snapshot = self.internal_session_snapshot(address)?;
11507        if snapshot.transport != TransportKind::Acp {
11508            return Err(failure(
11509                NodeFailureCode::UnsupportedTransport,
11510                "this verb requires a session spawned over the ACP transport",
11511            ));
11512        }
11513        Ok(snapshot)
11514    }
11515
11516    async fn dispatch_history_bounded(
11517        &self,
11518        address: &SessionAddress,
11519        command: ControlCommand,
11520        operation: HistoryOperation,
11521    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11522        self.validate_address(address)?;
11523        let started_after = self.current_sequence();
11524        let command_id = self
11525            .dispatch_bounded(
11526                command,
11527                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11528            )
11529            .await?;
11530        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11531        let mut scan_after = started_after;
11532        let mut accepted_after = None;
11533        loop {
11534            let events = self
11535                .history
11536                .lock()
11537                .unwrap_or_else(|poisoned| poisoned.into_inner())
11538                .events
11539                .iter()
11540                .filter(|envelope| envelope.sequence > scan_after)
11541                .cloned()
11542                .collect::<Vec<_>>();
11543            for envelope in events {
11544                scan_after = scan_after.max(envelope.sequence);
11545                let NodeEvent::Control {
11546                    address: event_address,
11547                    event,
11548                } = envelope.event
11549                else {
11550                    continue;
11551                };
11552                if event_address != *address {
11553                    continue;
11554                }
11555                if event.command_id == Some(command_id) {
11556                    match &event.event {
11557                        ControlEventKind::CommandRejected { .. } => {
11558                            return Err(failure(
11559                                NodeFailureCode::InvalidRequest,
11560                                "history request was rejected",
11561                            ));
11562                        }
11563                        ControlEventKind::HistoryRequested {
11564                            operation: requested,
11565                            ..
11566                        } if requested == &operation => {
11567                            accepted_after = Some(envelope.sequence);
11568                            continue;
11569                        }
11570                        _ => {}
11571                    }
11572                }
11573                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11574                    match (&operation, &event.event) {
11575                        (
11576                            HistoryOperation::Discover { .. },
11577                            ControlEventKind::HistoryDiscovered { .. },
11578                        )
11579                        | (
11580                            HistoryOperation::Load { .. },
11581                            ControlEventKind::HistoryLoaded { .. },
11582                        ) => return self.internal_session_snapshot(address),
11583                        (_, ControlEventKind::HistoryFailed { .. }) => {
11584                            return Err(failure(
11585                                NodeFailureCode::BackendOperationFailed,
11586                                "history operation failed",
11587                            ));
11588                        }
11589                        _ => {}
11590                    }
11591                }
11592            }
11593            if Instant::now() >= deadline {
11594                return Err(failure(
11595                    NodeFailureCode::BackendBusy,
11596                    if accepted_after.is_some() {
11597                        "history operation did not settle before the bounded deadline"
11598                    } else {
11599                        "history operation was not accepted before the bounded deadline"
11600                    },
11601                ));
11602            }
11603            sleep(Duration::from_millis(2)).await;
11604        }
11605    }
11606
11607    async fn discover_history(
11608        &self,
11609        address: &SessionAddress,
11610        limit: u16,
11611    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11612        let query = HistoryQuery {
11613            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11614            limit,
11615        };
11616        query
11617            .validate()
11618            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11619        let snapshot = self
11620            .dispatch_history_bounded(
11621                address,
11622                ControlCommand::DiscoverHistory {
11623                    instance_id: address.session.instance_id,
11624                    query: query.clone(),
11625                },
11626                HistoryOperation::Discover { query },
11627            )
11628            .await?;
11629        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11630            return Err(failure(
11631                NodeFailureCode::BackendOperationFailed,
11632                "history discovery settled into an invalid state",
11633            ));
11634        }
11635        Ok(snapshot.history.candidates)
11636    }
11637
11638    async fn load_history(
11639        &self,
11640        address: &SessionAddress,
11641        candidate_id: String,
11642    ) -> Result<HistorySessionRecord, NodeFailure> {
11643        validate_candidate_id(&candidate_id)
11644            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11645        let snapshot = self
11646            .dispatch_history_bounded(
11647                address,
11648                ControlCommand::LoadHistory {
11649                    instance_id: address.session.instance_id,
11650                    candidate_id: candidate_id.clone(),
11651                },
11652                HistoryOperation::Load {
11653                    candidate_id: candidate_id.clone(),
11654                },
11655            )
11656            .await?;
11657        if snapshot.history.pending.is_some()
11658            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11659        {
11660            return Err(failure(
11661                NodeFailureCode::BackendOperationFailed,
11662                "history load settled into an invalid state",
11663            ));
11664        }
11665        snapshot.history.loaded.ok_or_else(|| {
11666            failure(
11667                NodeFailureCode::BackendOperationFailed,
11668                "history load produced no bounded session",
11669            )
11670        })
11671    }
11672
11673    /// `allow_clean_detachment`: `false` for the on-demand
11674    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11675    /// managed record must stay untouched, still live, for the whole call);
11676    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11677    /// reacts to the very clean-exit event that detaches the record out from
11678    /// under this same call — see `session_record_export_target_matches`.
11679    async fn export_context_pack_for_session_record(
11680        &self,
11681        record_id: &SessionRecordId,
11682        session: &SessionAddress,
11683        allow_clean_detachment: bool,
11684    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11685        // C2 replaces a relayed failure's prose with a canned per-code
11686        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11687        // distinct refusals are indistinguishable to anyone downstream --
11688        // and a refusal here expires a continuation, which keeps no field
11689        // for why. The prose stays on the node, where it was written.
11690        let outcome = self
11691            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11692            .await;
11693        if let Err(failure) = &outcome {
11694            tracing::warn!(
11695                record = %record_id,
11696                code = ?failure.code,
11697                reason = %failure.message,
11698                "session-record context-pack export refused",
11699            );
11700        }
11701        outcome
11702    }
11703
11704    async fn export_context_pack_for_session_record_inner(
11705        &self,
11706        record_id: &SessionRecordId,
11707        session: &SessionAddress,
11708        allow_clean_detachment: bool,
11709    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11710        // Several steps here refuse with the same BackendOperationFailed
11711        // code and no prose of their own, so which step failed is
11712        // otherwise unrecoverable -- and a refusal here expires a
11713        // continuation.
11714        let step = |name: &'static str| move |failure: NodeFailure| {
11715            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11716            failure
11717        };
11718        let record = self.record(record_id).map_err(step("record-lookup"))?;
11719        // `provider_session` is `Some` only for a provider whose resolved
11720        // runtime policy admits live identity verification (raw PTY plus a
11721        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11722        // The PTY-sidecar-observation shape (no fleet provider today, but
11723        // capability-keyed rather than vendor-locked) hardcodes
11724        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11725        // so its records go straight to `Live` with `provider_session: None`
11726        // and stay that way for the record's entire life -- that is this
11727        // shape's permanent resting state, not a race with observation, and
11728        // `discover_history`/`load_history` below do not need an identity to
11729        // do their job for it. `IdentityPending` is the actual race (an
11730        // identity-admitted provider whose observation has not landed yet):
11731        // it keeps refusing here rather than falling back, because that
11732        // provider's own history the fallback would otherwise trust is not
11733        // guaranteed to be exact without the identity it is still waiting on.
11734        let identity = match record.provider_session.clone() {
11735            Some(identity) => {
11736                if identity.key != ProviderSessionKey::SessionId {
11737                    return Err(step("provider-identity-key")(failure(
11738                        NodeFailureCode::BackendOperationFailed,
11739                        "managed session provider identity cannot bind history exactly",
11740                    )));
11741                }
11742                Some(identity)
11743            }
11744            None if record.state == ManagedSessionState::IdentityPending => {
11745                return Err(step("provider-session-identity")(failure(
11746                    NodeFailureCode::BackendOperationFailed,
11747                    "managed session has not yet observed its provider session identity",
11748                )));
11749            }
11750            None => None,
11751        };
11752        self.revalidate_session_record_context_export(
11753            &record,
11754            identity.as_ref(),
11755            session,
11756            allow_clean_detachment,
11757        ).map_err(step("revalidate-before-discovery"))?;
11758        let candidates = self
11759            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11760            .await
11761            .map_err(step("discover-history"))?;
11762        // With no verified identity to filter by, the discovered set must
11763        // still resolve to exactly one candidate: the same ambiguity refusal
11764        // an identity filter would otherwise produce is the only safety
11765        // property left available for this shape.
11766        let candidate_id = select_history_candidate(
11767            &candidates,
11768            identity.as_ref().map(|identity| identity.id.as_str()),
11769        ).map_err(step("unique-candidate"))?;
11770        let loaded = self.load_history(session, candidate_id.clone()).await
11771            .map_err(step("load-history"))?;
11772        if let Some(identity) = identity.as_ref() {
11773            if loaded.session_id != identity.id {
11774                return Err(failure(
11775                    NodeFailureCode::SessionRecordConflict,
11776                    "loaded history identity does not match the managed session record",
11777                ));
11778            }
11779        }
11780        self.revalidate_session_record_context_export(
11781            &record,
11782            identity.as_ref(),
11783            session,
11784            allow_clean_detachment,
11785        )?;
11786        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11787            .map_err(step("revalidate-loaded-history"))?;
11788        let pack = self.materialize_context_pack(session).await
11789            .map_err(step("materialize-context-pack"))?;
11790        self.commit_context_pack_for_session_record(
11791            &record,
11792            identity.as_ref(),
11793            session,
11794            &candidate_id,
11795            &loaded,
11796            pack,
11797            allow_clean_detachment,
11798        )
11799    }
11800
11801    async fn export_context_pack(
11802        &self,
11803        address: &SessionAddress,
11804    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11805        let pack = self.materialize_context_pack(address).await?;
11806        self.commit_context_pack(pack)
11807    }
11808
11809    async fn materialize_context_pack(
11810        &self,
11811        address: &SessionAddress,
11812    ) -> Result<NodeContextPack, NodeFailure> {
11813        let snapshot = self.internal_session_snapshot(address)?;
11814        if !context_pack_source_status_is_usable(&snapshot.status) {
11815            return Err(failure(
11816                NodeFailureCode::InvalidRequest,
11817                "context export requires a running or successfully exited source session",
11818            ));
11819        }
11820        if snapshot.history.pending.is_some()
11821            || snapshot.history.loaded_candidate_id.is_none()
11822        {
11823            return Err(failure(
11824                NodeFailureCode::InvalidRequest,
11825                "context export requires settled loaded history",
11826            ));
11827        }
11828        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11829            failure(
11830                NodeFailureCode::InvalidRequest,
11831                "context export requires settled loaded history",
11832            )
11833        })?;
11834        let source_provider = snapshot.agent_id.clone();
11835        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11836        let repository = self
11837            .context_pack_repository(&address.workspace_id)
11838            .await?;
11839        let current = self.internal_session_snapshot(address)?;
11840        if !context_pack_source_status_is_usable(&current.status)
11841            || current.agent_id != source_provider
11842            || current.history.pending.is_some()
11843            || current.history.loaded_candidate_id != loaded_candidate_id
11844            || current.history.loaded.as_ref() != Some(&loaded)
11845        {
11846            return Err(failure(
11847                NodeFailureCode::ContextPackMaterializationFailed,
11848                "context source changed during bounded repository capture",
11849            ));
11850        }
11851        NodeContextPack::export_with_repository(
11852            ContextPackLineageReceipt {
11853                source_node_id: self.node_id.clone(),
11854                source_session: address.clone(),
11855                source_provider,
11856            },
11857            &loaded,
11858            Some(repository),
11859        )
11860        .map_err(|_| {
11861            failure(
11862                NodeFailureCode::ContextPackMaterializationFailed,
11863                "bounded context pack export failed",
11864            )
11865        })
11866    }
11867
11868    /// Write-through to the durable [`ContextPackStore`], if one is
11869    /// configured (`state_path` is `Some`). A node with no durable state
11870    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11871    /// open at all and this is a no-op, matching `persist_state`'s own
11872    /// "durability optional, degrade gracefully when unconfigured" idiom.
11873    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11874        let mut store = self
11875            .context_pack_store
11876            .lock()
11877            .unwrap_or_else(|poisoned| poisoned.into_inner());
11878        let Some(store) = store.as_mut() else {
11879            return Ok(());
11880        };
11881        store.commit(pack).map_err(|_| {
11882            failure(
11883                NodeFailureCode::ContextPackMaterializationFailed,
11884                "durable context pack commit failed",
11885            )
11886        })
11887    }
11888
11889    fn commit_context_pack(
11890        &self,
11891        pack: NodeContextPack,
11892    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11893        self.commit_context_pack_durably(&pack)?;
11894        self.context_catalog
11895            .write()
11896            .unwrap_or_else(|poisoned| poisoned.into_inner())
11897            .insert(pack)
11898            .map_err(|_| {
11899                failure(
11900                    NodeFailureCode::ContextPackMaterializationFailed,
11901                    "context pack catalog is full",
11902                )
11903            })
11904    }
11905
11906    fn commit_context_pack_for_session_record(
11907        &self,
11908        expected: &ManagedSessionRecord,
11909        identity: Option<&ProviderSessionIdentity>,
11910        session: &SessionAddress,
11911        candidate_id: &str,
11912        loaded: &HistorySessionRecord,
11913        pack: NodeContextPack,
11914        allow_clean_detachment: bool,
11915    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11916        let current_root = self.workspace_root(&expected.workspace_id)?;
11917        let _transaction = self
11918            .state_transaction
11919            .lock()
11920            .unwrap_or_else(|poisoned| poisoned.into_inner());
11921        let records = self
11922            .session_records
11923            .lock()
11924            .unwrap_or_else(|poisoned| poisoned.into_inner());
11925        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11926            failure(
11927                NodeFailureCode::SessionRecordConflict,
11928                "managed session record disappeared during context export",
11929            )
11930        })?;
11931        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11932            return Err(failure(
11933                NodeFailureCode::SessionRecordConflict,
11934                "managed session record changed during context export",
11935            ));
11936        }
11937        let bindings = self
11938            .session_bindings
11939            .lock()
11940            .unwrap_or_else(|poisoned| poisoned.into_inner());
11941        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11942            failure(
11943                NodeFailureCode::SessionRecordConflict,
11944                "managed session runtime binding disappeared during context export",
11945            )
11946        })?;
11947        let runtime = self
11948            .handle
11949            .snapshot()
11950            .sessions
11951            .iter()
11952            .find(|snapshot| {
11953                snapshot.instance_id == session.session.instance_id
11954                    && snapshot.generation == session.session.generation
11955            })
11956            .cloned()
11957            .ok_or_else(|| {
11958                failure(
11959                    NodeFailureCode::SessionRecordConflict,
11960                    "managed session runtime disappeared during context export",
11961                )
11962            })?;
11963        let receipt = pack.receipt();
11964        if !session_record_context_export_binding_is_exact(
11965            expected,
11966            current,
11967            identity,
11968            session,
11969            binding,
11970            &runtime.agent_id,
11971            &current_root,
11972            allow_clean_detachment,
11973        ) || !session_record_context_export_source_is_usable(
11974            current,
11975            &runtime.status,
11976            allow_clean_detachment,
11977        ) || runtime.history.pending.is_some()
11978            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11979            || runtime.history.loaded.as_ref() != Some(loaded)
11980            || receipt.lineage.source_node_id != self.node_id
11981            || receipt.lineage.source_session != *session
11982            || receipt.lineage.source_provider != current.provider
11983            || receipt.source_message_count != loaded.message_count
11984        {
11985            return Err(failure(
11986                NodeFailureCode::SessionRecordConflict,
11987                "managed session source changed before context catalog commit",
11988            ));
11989        }
11990        self.commit_context_pack_durably(&pack)?;
11991        self.context_catalog
11992            .write()
11993            .unwrap_or_else(|poisoned| poisoned.into_inner())
11994            .insert(pack)
11995            .map_err(|_| {
11996                failure(
11997                    NodeFailureCode::ContextPackMaterializationFailed,
11998                    "context pack catalog is full",
11999                )
12000            })
12001    }
12002
12003    async fn context_pack_repository(
12004        &self,
12005        workspace_id: &WorkspaceId,
12006    ) -> Result<ContextPackRepository, NodeFailure> {
12007        let first = self
12008            .context_pack_repository_observation(workspace_id)
12009            .await?;
12010        let second = self
12011            .context_pack_repository_observation(workspace_id)
12012            .await?;
12013        stable_context_pack_repository(first, second)
12014    }
12015
12016    async fn context_pack_repository_observation(
12017        &self,
12018        workspace_id: &WorkspaceId,
12019    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12020        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12021        let head = if git.is_repository {
12022            let root = self.workspace_root(workspace_id)?;
12023            match resolve_base_commit_with_timeout(
12024                &root,
12025                "HEAD",
12026                GIT_COMMAND_TIMEOUT_MS,
12027            )
12028            .await
12029            {
12030                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12031                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12032                    ContextPackRepositoryHead::Unborn
12033                }
12034                Err(_) => {
12035                    return Err(failure(
12036                        NodeFailureCode::ContextPackMaterializationFailed,
12037                        "context pack HEAD observation failed",
12038                    ));
12039                }
12040            }
12041        } else {
12042            ContextPackRepositoryHead::NotRepository
12043        };
12044        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12045        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12046            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12047                failure(
12048                    NodeFailureCode::InvalidRepositoryPath,
12049                    "context pack selected file path is invalid",
12050                )
12051            })?;
12052            match self.read_workspace_file(workspace_id.clone(), path).await {
12053                Ok(WorkspaceFileRead {
12054                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12055                    ..
12056                }) => files.push(ContextPackRepositoryFileSource::utf8(
12057                    selected_path,
12058                    text,
12059                    byte_len,
12060                )),
12061                Ok(WorkspaceFileRead {
12062                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12063                    ..
12064                }) => files.push(ContextPackRepositoryFileSource::skipped(
12065                    selected_path,
12066                    ContextPackSelectedFileSkipReason::NonUtf8,
12067                    Some(byte_len),
12068                )),
12069                Ok(WorkspaceFileRead {
12070                    content: WorkspaceFileContent::TooLarge { .. },
12071                    ..
12072                }) => files.push(ContextPackRepositoryFileSource::skipped(
12073                    selected_path,
12074                    ContextPackSelectedFileSkipReason::TooLarge,
12075                    None,
12076                )),
12077                Err(error) => {
12078                    let reason = if matches!(
12079                        error.code,
12080                        NodeFailureCode::InvalidRepositoryPath
12081                            | NodeFailureCode::RepositoryFileNotRegular
12082                            | NodeFailureCode::RepositoryPathUnsafe
12083                    ) {
12084                        ContextPackSelectedFileSkipReason::Unsafe
12085                    } else {
12086                        ContextPackSelectedFileSkipReason::Unavailable
12087                    };
12088                    files.push(ContextPackRepositoryFileSource::skipped(
12089                        selected_path,
12090                        reason,
12091                        None,
12092                    ));
12093                }
12094            }
12095        }
12096        Ok(ContextPackRepositoryObservation { head, git, files })
12097    }
12098
12099    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12100        let referenced_by_binding = self
12101            .session_bindings
12102            .lock()
12103            .unwrap_or_else(|poisoned| poisoned.into_inner())
12104            .values()
12105            .any(|binding| {
12106                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12107            });
12108        let referenced_by_record = self
12109            .session_records
12110            .lock()
12111            .unwrap_or_else(|poisoned| poisoned.into_inner())
12112            .records
12113            .values()
12114            .any(|record| record.context_id.as_ref() == Some(context_id));
12115        let referenced_by_materialization = self
12116            .materializations
12117            .lock()
12118            .unwrap_or_else(|poisoned| poisoned.into_inner())
12119            .values()
12120            .any(|ownership| {
12121                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12122            });
12123        if referenced_by_binding
12124            || referenced_by_record
12125            || referenced_by_materialization
12126        {
12127            return Err(failure(
12128                NodeFailureCode::ContextPackBusy,
12129                "context pack is still referenced",
12130            ));
12131        }
12132        let mut catalog = self.context_catalog
12133            .write()
12134            .unwrap_or_else(|poisoned| poisoned.into_inner());
12135        if catalog.get(context_id).is_none() {
12136            return Err(failure(
12137                NodeFailureCode::UnknownContextPack,
12138                "context pack is unavailable",
12139            ));
12140        }
12141        self.spawn_idempotency
12142            .lock()
12143            .unwrap_or_else(|poisoned| poisoned.into_inner())
12144            .entries
12145            .retain(|_, entry| !entry.value.references_context(context_id));
12146        let removed = catalog.remove(context_id);
12147        debug_assert!(removed.is_some(), "validated context pack remains present");
12148        Ok(())
12149    }
12150
12151    async fn dispatch_input_bounded(
12152        &self,
12153        address: &SessionAddress,
12154        action: InputAction,
12155    ) -> Result<(), NodeFailure> {
12156        let started_after = self.current_sequence();
12157        self.pty_input_marks
12158            .lock()
12159            .unwrap_or_else(|poisoned| poisoned.into_inner())
12160            .insert(address.session.instance_id, (Instant::now(), false));
12161        self.pty_input_wake.notify_one();
12162        let command_id = self
12163            .dispatch_bounded(
12164                ControlCommand::SendInput {
12165                    instance_id: address.session.instance_id,
12166                    action,
12167                },
12168                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12169            )
12170            .await?;
12171        // A semantic prompt may spend the provider's full readiness budget before
12172        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12173        // but strictly beyond every readiness policy in this node's catalog.
12174        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12175        let mut scan_after = started_after;
12176        let mut accepted_after = None;
12177        loop {
12178            let events = self
12179                .history
12180                .lock()
12181                .unwrap_or_else(|poisoned| poisoned.into_inner())
12182                .events
12183                .iter()
12184                .filter(|envelope| envelope.sequence > scan_after)
12185                .cloned()
12186                .collect::<Vec<_>>();
12187            for envelope in events {
12188                scan_after = scan_after.max(envelope.sequence);
12189                let NodeEvent::Control {
12190                    address: event_address,
12191                    event,
12192                } = envelope.event
12193                else {
12194                    continue;
12195                };
12196                if event_address != *address {
12197                    continue;
12198                }
12199                if event.command_id == Some(command_id) {
12200                    match &event.event {
12201                        ControlEventKind::CommandRejected { message } => {
12202                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12203                        }
12204                        ControlEventKind::InputRequested { .. } => {
12205                            accepted_after = Some(envelope.sequence);
12206                            continue;
12207                        }
12208                        _ => {}
12209                    }
12210                }
12211                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12212                    match &event.event {
12213                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12214                        ControlEventKind::InputFailed { message, .. } => {
12215                            return Err(failure(
12216                                NodeFailureCode::BackendOperationFailed,
12217                                message,
12218                            ));
12219                        }
12220                        _ => {}
12221                    }
12222                }
12223            }
12224            if Instant::now() >= deadline {
12225                return Err(failure(
12226                    NodeFailureCode::BackendBusy,
12227                    if accepted_after.is_some() {
12228                        "input was accepted but did not settle before the bounded deadline"
12229                    } else {
12230                        "input was not accepted before the bounded deadline"
12231                    },
12232                ));
12233            }
12234            sleep(Duration::from_millis(2)).await;
12235        }
12236    }
12237
12238    async fn dispatch_resize_bounded(
12239        &self,
12240        address: &SessionAddress,
12241        size: gate4agent_types::TerminalSize,
12242    ) -> Result<(), NodeFailure> {
12243        let started_after = self.current_sequence();
12244        let command_id = self
12245            .dispatch_bounded(
12246                ControlCommand::Resize {
12247                    instance_id: address.session.instance_id,
12248                    size,
12249                },
12250                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12251            )
12252            .await?;
12253        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12254        let mut scan_after = started_after;
12255        let mut accepted_after = None;
12256        loop {
12257            let events = self
12258                .history
12259                .lock()
12260                .unwrap_or_else(|poisoned| poisoned.into_inner())
12261                .events
12262                .iter()
12263                .filter(|envelope| envelope.sequence > scan_after)
12264                .cloned()
12265                .collect::<Vec<_>>();
12266            for envelope in events {
12267                scan_after = scan_after.max(envelope.sequence);
12268                let NodeEvent::Control {
12269                    address: event_address,
12270                    event,
12271                } = envelope.event
12272                else {
12273                    continue;
12274                };
12275                if event_address != *address {
12276                    continue;
12277                }
12278                if event.command_id == Some(command_id) {
12279                    match &event.event {
12280                        ControlEventKind::CommandRejected { message } => {
12281                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12282                        }
12283                        ControlEventKind::ResizeRequested {
12284                            size: requested_size,
12285                            ..
12286                        } if *requested_size == size => {
12287                            accepted_after = Some(envelope.sequence);
12288                            continue;
12289                        }
12290                        _ => {}
12291                    }
12292                }
12293                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12294                    match &event.event {
12295                        ControlEventKind::Resized { size: settled_size }
12296                            if *settled_size == size =>
12297                        {
12298                            return Ok(());
12299                        }
12300                        ControlEventKind::ResizeFailed { message } => {
12301                            return Err(failure(
12302                                NodeFailureCode::BackendOperationFailed,
12303                                message,
12304                            ));
12305                        }
12306                        _ => {}
12307                    }
12308                }
12309            }
12310            if Instant::now() >= deadline {
12311                return Err(failure(
12312                    NodeFailureCode::BackendBusy,
12313                    if accepted_after.is_some() {
12314                        "resize was accepted but did not settle before the bounded deadline"
12315                    } else {
12316                        "resize was not accepted before the bounded deadline"
12317                    },
12318                ));
12319            }
12320            sleep(Duration::from_millis(2)).await;
12321        }
12322    }
12323
12324    async fn spawn_session(
12325        &self,
12326        workspace_id: WorkspaceId,
12327        provider: AgentId,
12328        mode: SessionMode,
12329        terminal_size: gate4agent_types::TerminalSize,
12330        initial_prompt: Option<String>,
12331    ) -> Result<SessionAddress, NodeFailure> {
12332        if self.managed_worktrees.lock()
12333            .unwrap_or_else(|poisoned| poisoned.into_inner())
12334            .lease_for_workspace(&workspace_id)
12335            .is_some()
12336        {
12337            return Err(failure(
12338                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12339                "managed worktree targets may only be spawned through their lease request",
12340            ));
12341        }
12342        self
12343            .spawn_session_with_deadline(
12344                workspace_id,
12345                provider,
12346                mode,
12347                terminal_size,
12348                initial_prompt,
12349                None,
12350                None,
12351                None,
12352                None,
12353                None,
12354                SpawnRecordPolicy::ProviderIdentityOnly,
12355                None,
12356                &[],
12357                None,
12358                // `NodeRequest::Spawn` (this method's only caller) carries no
12359                // approval-level selection of its own -- see
12360                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12361                // the one operator-wire path that can choose something other
12362                // than the axis default.
12363                ApprovalLevel::default(),
12364                None,
12365                None,
12366            )
12367            .await
12368            .map(|(session, _runtime_policy)| session)
12369    }
12370
12371    async fn spawn_session_with_deadline(
12372        &self,
12373        workspace_id: WorkspaceId,
12374        provider: AgentId,
12375        mode: SessionMode,
12376        terminal_size: gate4agent_types::TerminalSize,
12377        initial_prompt: Option<String>,
12378        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12379        bundle: Option<ResolvedBundleReceipt>,
12380        context: Option<ResolvedContextPackReceipt>,
12381        managed_authority: Option<ManagedWorktreeLeaseId>,
12382        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12383        record_policy: SpawnRecordPolicy,
12384        deadline: Option<Instant>,
12385        required_capabilities: &[ProviderRuntimeCapability],
12386        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12387        approval_level: ApprovalLevel,
12388        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12389        // Opaque dig2browser station profile id (ids only). Feature
12390        // `dig2-station-probe`: after probe at resolve, spawn takes an
12391        // exclusive node-local lease; cleared on session end. Never
12392        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12393        // already happened on the receipt path).
12394        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12395    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12396        let reserved_lease = self.managed_worktrees.lock()
12397            .unwrap_or_else(|poisoned| poisoned.into_inner())
12398            .lease_for_workspace(&workspace_id);
12399        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12400            (Some(expected), Some(provided)) if expected == provided => {}
12401            (None, None) => {}
12402            _ => {
12403                return Err(failure(
12404                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12405                    "spawn lacks exact managed worktree lease authority",
12406                ));
12407            }
12408        }
12409        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12410        // bindings) on the same workspace cwd. Parallel path: managed worktree
12411        // spawn so each profile gets its own workspace_id.
12412        self.ensure_workspace_environment_profile_compatible(
12413            &workspace_id,
12414            environment_profile.as_ref(),
12415        )?;
12416        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12417            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12418            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12419            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12420            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12421        };
12422        // This session's own transport -- computed once, here, from `mode`,
12423        // and reused both to admit spawn (below) and, further down, to
12424        // compute the final `runtime_policy` this session actually operates
12425        // under (`provider_runtime::policy_for_transport`). Registration
12426        // reuses this exact value rather than recomputing it a second time.
12427        let transport = match mode {
12428            SessionMode::Pty => TransportKind::Pty,
12429            SessionMode::Inline => TransportKind::Pipe,
12430            SessionMode::Acp => TransportKind::Acp,
12431        };
12432        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12433        let acp_transport = self.provider_supports_acp_transport(&provider);
12434        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12435            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12436                NodeFailureCode::UnsupportedSpawnCapability,
12437                "pre-admitted provider runtime no longer satisfies spawn requirements",
12438            ))?;
12439            runtime_policy
12440        } else if let Some(deadline) = deadline {
12441            let remaining = spawn_deadline_remaining(deadline)?;
12442            timeout(
12443                remaining,
12444                self.admit_provider_runtime(&provider, runtime_requirement),
12445            )
12446            .await
12447            .map_err(|_| {
12448                failure(
12449                    NodeFailureCode::SpawnDeadlineExceeded,
12450                    "provider admission exceeded the spawn deadline",
12451                )
12452            })??
12453        } else {
12454            self.admit_provider_runtime(&provider, runtime_requirement).await?
12455        };
12456        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12457        // The single point where this session's `runtime_policy` becomes
12458        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12459        // branch above) only ever compute a PTY-terminal-verification-shaped
12460        // value, sound outright only for `TransportKind::Pty` (Pipe still
12461        // relies on that same value downstream -- see `policy_for_transport`'s
12462        // own doc for why). `policy_for_transport` is the explicit,
12463        // exhaustive match Item 1 asks for -- this is the ONE place its
12464        // output becomes the session's `runtime_policy`, so every later
12465        // reader (`ControlCommand::Start`, `session_bindings`, and every
12466        // engine gate that reads `state.runtime_policy`) sees the same,
12467        // correctly-computed value.
12468        let runtime_policy =
12469            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12470        if required_capabilities
12471            .iter()
12472            .any(|capability| !runtime_policy.admits(*capability))
12473        {
12474            return Err(failure(
12475                NodeFailureCode::UnsupportedSpawnCapability,
12476                "provider runtime does not admit a required spawn capability",
12477            ));
12478        }
12479        if let Some(deadline) = deadline {
12480            spawn_deadline_remaining(deadline)?;
12481        }
12482        self.ensure_binding_capacity()?;
12483        let working_directory = self.workspace_root(&workspace_id)?;
12484        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12485        let session = SessionKey {
12486            instance_id,
12487            generation: SessionGeneration(1),
12488        };
12489        let address = SessionAddress {
12490            workspace_id: workspace_id.clone(),
12491            session,
12492        };
12493        // This is the request-accepted checkpoint: admission (managed
12494        // worktree authority, runtime capability, dispatch deadline) already
12495        // passed and a concrete session identity is minted. It is a distinct
12496        // state from the process actually running (`ControlEventKind::Running`,
12497        // logged in `publish_control`) or the terminal outcome (`Exited`/
12498        // `Failed`, logged there too) -- this line exists so that gap is no
12499        // longer silent.
12500        tracing::info!(
12501            node_id = %self.node_id,
12502            workspace_id = %workspace_id,
12503            provider = %provider,
12504            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12505            session = ?address.session,
12506            "session spawn request accepted",
12507        );
12508        let mut prepared_spawn = SpawnPreparer::prepare(
12509            self,
12510            SpawnPrepareRequest {
12511                instance_id,
12512                address: &address,
12513                provider: &provider,
12514                mode,
12515                environment_profile: environment_profile.as_ref(),
12516                bundle: bundle.as_ref(),
12517                context: context.as_ref(),
12518                managed_authority: managed_authority.as_ref(),
12519                harness_mcp,
12520                approval_level,
12521                network_allowlist: network_allowlist.as_ref(),
12522                browser_profile_id,
12523            },
12524        )?;
12525        let materialization_id = prepared_spawn.materialization_id.take();
12526        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12527        let record_id = self.bind_spawn_session_with_materialization(
12528            &address,
12529            provider.clone(),
12530            mode,
12531            runtime_policy,
12532            record_policy,
12533            environment_profile,
12534            bundle,
12535            context,
12536            materialization_id,
12537        )?;
12538        prepared_spawn.retain_session_guards();
12539        // `transport` was already computed above, alongside this session's
12540        // `runtime_policy`; reused here for `Register` rather than derived a
12541        // second time.
12542        let agent_id = provider;
12543        let register_command_id = match self.dispatch_bounded(
12544            ControlCommand::Register {
12545                instance_id,
12546                agent_id,
12547                transport,
12548            },
12549            dispatch_timeout,
12550        )
12551        .await {
12552            Ok(command_id) => command_id,
12553            Err(error) => {
12554                if let Some(binding) = self.remove_binding(&address) {
12555                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12556                }
12557                if let Some(record_id) = record_id.as_ref() {
12558                    self.discard_record(record_id)?;
12559                }
12560                return Err(spawn_dispatch_error(error, deadline));
12561            }
12562        };
12563        let start_timeout = match spawn_dispatch_timeout(deadline) {
12564            Ok(timeout) => timeout,
12565            Err(error) => {
12566                let recovery = self
12567                    .rollback_spawn(
12568                        &address,
12569                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12570                    )
12571                    .await;
12572                return match recovery {
12573                    Ok(()) => {
12574                        if let Some(record_id) = record_id.as_ref() {
12575                            self.discard_record(record_id)?;
12576                        }
12577                        Err(error)
12578                    }
12579                    Err(recovery_error) => {
12580                        if let Some(record_id) = record_id.as_ref() {
12581                            self.mark_record_error(
12582                                record_id,
12583                                "spawn deadline elapsed and registration recovery failed",
12584                            )?;
12585                        }
12586                        Err(recovery_error)
12587                    }
12588                };
12589            }
12590        };
12591        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12592            if let Err(recovery_error) = self
12593                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12594                .await
12595            {
12596                return Err(recovery_error);
12597            }
12598            if let Some(record_id) = record_id.as_ref() {
12599                self.discard_record(record_id)?;
12600            }
12601            return Err(start_error);
12602        }
12603        let start_result = self
12604            .dispatch_bounded(
12605                ControlCommand::Start {
12606                    instance_id,
12607                    runtime_policy,
12608                    request: StartRequest {
12609                        working_directory,
12610                        terminal_size,
12611                        initial_prompt,
12612                        session_options: None,
12613                        approval_level,
12614                    },
12615                },
12616                start_timeout,
12617            )
12618            .await;
12619        let start_command_id = match start_result {
12620            Ok(command_id) => command_id,
12621            Err(start_error) => {
12622                let start_error = spawn_dispatch_error(start_error, deadline);
12623                let recovery = self
12624                    .rollback_spawn(
12625                        &address,
12626                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12627                    )
12628                    .await;
12629                return match recovery {
12630                    Ok(()) => {
12631                        if let Some(record_id) = record_id.as_ref() {
12632                            self.discard_record(record_id)?;
12633                        }
12634                        Err(start_error)
12635                    }
12636                    Err(recovery_error) => {
12637                        let diagnostic = format!(
12638                            "start failed and registration recovery failed: {}",
12639                            recovery_error.message,
12640                        );
12641                        if let Some(record_id) = record_id.as_ref() {
12642                            self.mark_record_error(record_id, &diagnostic)?;
12643                        }
12644                        Err(failure(recovery_error.code, &diagnostic))
12645                    }
12646                };
12647            }
12648        };
12649
12650        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12651        loop {
12652            // `Register`/`Start` only fail synchronously for a port-level
12653            // ingress problem (see `dispatch_bounded`); the kernel refusing
12654            // either command's content -- an unsupported transport, an
12655            // unknown agent -- lands one drive-loop tick later, and no
12656            // session ever appears in `self.handle.snapshot()` for it to
12657            // notice below. Check for that named reason before falling
12658            // through to the blind presence poll, so a rejected spawn
12659            // reports why instead of just timing out.
12660            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12661                .or_else(|| self.take_rejected_command_reason(start_command_id))
12662            {
12663                self.rollback_spawn(
12664                    &address,
12665                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12666                ).await.map_err(|error| {
12667                        failure(
12668                            error.code,
12669                            &format!(
12670                                "spawn was rejected ({}) and registration recovery failed: {}",
12671                                reason.message, error.message
12672                            ),
12673                        )
12674                    })?;
12675                if let Some(record_id) = record_id.as_ref() {
12676                    self.discard_record(record_id)?;
12677                }
12678                let code = if reason.unsupported_transport {
12679                    NodeFailureCode::UnsupportedTransport
12680                } else {
12681                    NodeFailureCode::BackendOperationFailed
12682                };
12683                return Err(failure(code, &reason.message));
12684            }
12685            if let Some(current) = self
12686                .handle
12687                .snapshot()
12688                .sessions
12689                .iter()
12690                .find(|current| current.instance_id == instance_id)
12691            {
12692                if current.generation != session.generation {
12693                    let divergence = failure(
12694                        NodeFailureCode::StaleGeneration,
12695                        "spawn generation diverged",
12696                    );
12697                    return match self
12698                        .rollback_spawn(
12699                            &address,
12700                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12701                        )
12702                        .await
12703                    {
12704                        Ok(()) => {
12705                            if let Some(record_id) = record_id.as_ref() {
12706                                self.discard_record(record_id)?;
12707                            }
12708                            Err(divergence)
12709                        }
12710                        Err(recovery_error) => {
12711                            if let Some(record_id) = record_id.as_ref() {
12712                                self.mark_record_error(
12713                                    record_id,
12714                                    "spawn generation diverged and recovery failed",
12715                                )?;
12716                            }
12717                            Err(recovery_error)
12718                        }
12719                    };
12720                }
12721                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12722                    prepared_spawn.retain_launch_guards();
12723                    return Ok((address, runtime_policy));
12724                }
12725            }
12726            if Instant::now() >= commit_deadline {
12727                self.rollback_spawn(
12728                    &address,
12729                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12730                ).await.map_err(|error| {
12731                        failure(
12732                            error.code,
12733                            &format!(
12734                                "start did not commit and registration recovery failed: {}",
12735                                error.message
12736                            ),
12737                        )
12738                    })?;
12739                if let Some(record_id) = record_id.as_ref() {
12740                    self.discard_record(record_id)?;
12741                }
12742                return Err(failure(
12743                    if deadline.is_some() {
12744                        NodeFailureCode::SpawnDeadlineExceeded
12745                    } else {
12746                        NodeFailureCode::BackendBusy
12747                    },
12748                    "start did not commit before the bounded deadline; registration was removed",
12749                ));
12750            }
12751            sleep(Duration::from_millis(2)).await;
12752        }
12753    }
12754
12755    fn effective_spawn_runtime_policy(
12756        &self,
12757        runtime_policy: ProviderRuntimePolicy,
12758    ) -> ProviderRuntimePolicy {
12759        #[cfg(feature = "fixture")]
12760        let runtime_policy = if self.fixture_semantic_hook_policy {
12761            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12762                .expect("monitoring Hook fixture policy is internally valid")
12763        } else {
12764            runtime_policy
12765        };
12766        runtime_policy
12767    }
12768
12769    fn verify_harness_mcp_before_start(
12770        &self,
12771        prepared: Option<&PreparedHarnessMcpSpawn>,
12772    ) -> Result<(), NodeFailure> {
12773        prepared
12774            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12775            .transpose()
12776            .map(|_| ())
12777            .map_err(harness_mcp_failure)
12778    }
12779
12780    async fn rollback_spawn(
12781        &self,
12782        address: &SessionAddress,
12783        commit_timeout: Duration,
12784    ) -> Result<(), NodeFailure> {
12785        self.dispatch_bounded(
12786            ControlCommand::Remove {
12787                instance_id: address.session.instance_id,
12788            },
12789            commit_timeout,
12790        )
12791        .await?;
12792        self.wait_until_removed(address, commit_timeout).await?;
12793        if let Some(binding) = self.remove_binding(address) {
12794            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12795        }
12796        Ok(())
12797    }
12798
12799    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12800        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12801        self.dispatch_bounded(
12802            ControlCommand::Remove {
12803                instance_id: address.session.instance_id,
12804            },
12805            commit_timeout,
12806        )
12807            .await?;
12808        self.wait_until_removed(address, commit_timeout).await?;
12809        let removed = self.remove_binding(address);
12810        if let Some(binding) = removed.as_ref() {
12811            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12812        }
12813        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12814            let snapshot = self.managed_worktrees.lock()
12815                .unwrap_or_else(|poisoned| poisoned.into_inner())
12816                .release_session(
12817                    &lease_id,
12818                    self.incarnation_id,
12819                    address.session.instance_id,
12820                    address.session.generation,
12821                    unix_time_ms(),
12822                );
12823            if self.persist_state().is_err() {
12824                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12825            }
12826            if let Some(snapshot) = snapshot {
12827                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12828            }
12829            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12830        }
12831        Ok(())
12832    }
12833
12834    /// Polls until `address`'s session leaves the engine's own session map,
12835    /// or the bounded deadline expires.
12836    ///
12837    /// A `Remove` sent while the session is not yet in a terminal status
12838    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12839    /// once by the engine (`SessionStatus::allows_remove`) and
12840    /// then discarded -- nothing re-issues it on its own, so the removal
12841    /// that would satisfy this wait never arrives and the caller was
12842    /// guaranteed to time out even though the session settles microseconds
12843    /// later. So this re-dispatches on every poll tick: a session that
12844    /// settles mid-wait -- the common case right after a `Stop` -- still
12845    /// gets removed inside the same bounded deadline.
12846    ///
12847    /// It re-dispatches only once the status will ACCEPT the command. A
12848    /// rejected re-dispatch is not the harmless no-op this loop first
12849    /// assumed: every rejection publishes a `ControlEventKind::Command
12850    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12851    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12852    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12853    /// out to the harness and every attached client. Gated, the same
12854    /// teardown sends exactly one `Remove`, at the first tick the engine
12855    /// can act on it.
12856    ///
12857    /// The status is read from the snapshot this loop already takes, so
12858    /// the gate costs nothing extra. It races -- the status can move
12859    /// between the read and the dispatch -- and that is fine: a rare lost
12860    /// race is one rejection, and the next tick retries.
12861    ///
12862    /// Re-dispatch is separately rate-limited to
12863    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12864    /// leaves a second burst behind it. Once the session settles, every
12865    /// 2ms tick sent another `Remove` while the accepted one was still in
12866    /// flight, and each of those landed on an instance the engine had
12867    /// already dropped -- `agent instance … is not registered`, observed
12868    /// live as a tail of rejections immediately after the `session
12869    /// removed` line. The presence poll stays at 2ms so the wait still
12870    /// exits promptly; only the sending slows down.
12871    ///
12872    /// Rate-limited rather than once-only on purpose: the status gate
12873    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12874    /// others (a pending operation, an in-flight capability probe or
12875    /// history request) that clear on their own. A single shot would turn
12876    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12877    /// get their retries, just twenty of them across the deadline instead
12878    /// of a thousand.
12879    async fn wait_until_removed(
12880        &self,
12881        address: &SessionAddress,
12882        commit_timeout: Duration,
12883    ) -> Result<(), NodeFailure> {
12884        let deadline = Instant::now() + commit_timeout;
12885        let mut next_dispatch = Instant::now();
12886        loop {
12887            let snapshot = self.handle.snapshot();
12888            let status = snapshot
12889                .sessions
12890                .iter()
12891                .find(|session| session.instance_id == address.session.instance_id)
12892                .map(|session| session.status.clone());
12893            let Some(status) = status else {
12894                return Ok(());
12895            };
12896            let now = Instant::now();
12897            if now >= deadline {
12898                return Err(failure(
12899                    NodeFailureCode::BackendBusy,
12900                    "remove did not commit before the bounded deadline; session binding was retained",
12901                ));
12902            }
12903            if status.allows_remove() && now >= next_dispatch {
12904                let _ = self.dispatch(ControlCommand::Remove {
12905                    instance_id: address.session.instance_id,
12906                });
12907                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12908            }
12909            sleep(Duration::from_millis(2)).await;
12910        }
12911    }
12912
12913    fn resync(&self, after_sequence: u64) -> NodeResponse {
12914        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12915        let event_sequence = history.last_sequence;
12916        let oldest_available_sequence = history.replay_floor_sequence;
12917        let events = history
12918            .events
12919            .iter()
12920            .filter(|event| event.sequence > after_sequence)
12921            .cloned()
12922            .collect();
12923        drop(history);
12924        NodeResponse::Resync {
12925            event_sequence,
12926            oldest_available_sequence,
12927            snapshot: self.snapshot(),
12928            events,
12929        }
12930    }
12931}
12932
12933/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12934/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12935/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12936/// "name what crossed this boundary" reasoning, one layer upstream of it).
12937fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12938    match kind {
12939        AgentStreamChunkKindV1::Text { .. } => "text",
12940        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12941        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12942        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12943        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12944        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12945        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12946    }
12947}
12948
12949/// Serves this wire on the local endpoint, and -- when `call_home` names a
12950/// relay -- over an outbound connection to it as well.
12951///
12952/// The two live in one future rather than a fourth arm of `run`'s own
12953/// `select!` because they are one concern: this is where the node's wire
12954/// is served, and the direction a given connection was opened in does not
12955/// change what happens on it. It also means the existing graceful drain
12956/// below covers both without being duplicated.
12957///
12958/// Either side finishing ends the pair, which is correct: the listener
12959/// only returns on shutdown or a bind failure, and `call_home_loop` only
12960/// returns on shutdown -- a failed relay connection is retried inside it,
12961/// never propagated out.
12962async fn accept_connections(
12963    endpoint: &str,
12964    call_home: Option<std::net::SocketAddr>,
12965    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12966    shared: Arc<NodeShared>,
12967) -> Result<(), NodeServerError> {
12968    let mut connections = JoinSet::new();
12969    let result = match call_home {
12970        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12971        Some(relay) => {
12972            let listener =
12973                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12974            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12975            tokio::pin!(listener);
12976            tokio::pin!(dialler);
12977            tokio::select! {
12978                result = &mut listener => result,
12979                result = &mut dialler => result,
12980            }
12981        }
12982    };
12983    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12984    while !connections.is_empty() {
12985        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12986        if remaining.is_zero()
12987            || timeout(remaining, connections.join_next()).await.is_err()
12988        {
12989            break;
12990        }
12991    }
12992    connections.abort_all();
12993    while connections.join_next().await.is_some() {}
12994    result
12995}
12996
12997/// Backoff ladder for a relay that will not answer, capped rather than
12998/// unbounded: a node whose relay is down is not broken and should keep
12999/// trying forever, but it should not spend a core doing it.
13000const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13001
13002/// Dials the relay, serves this wire over the connection, and does it
13003/// again when the connection ends.
13004///
13005/// The node is the SERVER on the socket it just opened. That inversion is
13006/// the whole point: `serve_connection` is the same function the local
13007/// listener feeds, and it neither knows nor cares which end placed the
13008/// call. The one thing this direction adds is the preface -- the relay
13009/// accepted a socket and cannot know whose it is, so the node says its
13010/// name before the handshake it would otherwise begin with.
13011///
13012/// Never returns an error. A relay that is down, refusing, or speaking a
13013/// different protocol is a condition to retry, not a reason to take the
13014/// node's local endpoint down with it; the only way out is shutdown.
13015async fn call_home_loop(
13016    relay: std::net::SocketAddr,
13017    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13018    shared: Arc<NodeShared>,
13019) -> Result<(), NodeServerError> {
13020    let mut failures = 0_usize;
13021    loop {
13022        if shared.shutdown.load(Ordering::Acquire) {
13023            return Ok(());
13024        }
13025        let permit = tokio::select! {
13026            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13027                permit.map_err(|_| NodeServerError::PreauthClosed)?
13028            }
13029            _ = shared.shutdown_notify.notified() => return Ok(()),
13030        };
13031        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13032            Ok(()) => {
13033                // A clean end is the relay hanging up, which it does on
13034                // its own restart. Reconnect immediately rather than
13035                // serving the backoff a peer that is plainly there.
13036                failures = 0;
13037                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13038            }
13039            Err(error) => {
13040                failures = failures.saturating_add(1);
13041                tracing::warn!(
13042                    node_id = %shared.node_id,
13043                    %relay,
13044                    cause = %error,
13045                    attempt = failures,
13046                    "call-home connection failed",
13047                );
13048                let backoff = CALL_HOME_BACKOFFS_MS
13049                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13050                tokio::select! {
13051                    _ = sleep(Duration::from_millis(backoff)) => {}
13052                    _ = shared.shutdown_notify.notified() => return Ok(()),
13053                }
13054            }
13055        }
13056    }
13057}
13058
13059/// One call-home attempt: connect, name this node, then serve.
13060async fn dial_relay_and_serve(
13061    relay: std::net::SocketAddr,
13062    identity: Option<&hatchery_enroll::NodeIdentity>,
13063    shared: Arc<NodeShared>,
13064    preauth_permit: OwnedSemaphorePermit,
13065) -> Result<(), NodeServerError> {
13066    let mut stream = tokio::net::TcpStream::connect(relay)
13067        .await
13068        .map_err(NodeServerError::CallHomeTransport)?;
13069    // Nagle off for the same reason the rest of this wire wants it off:
13070    // the traffic is small request/response frames, and coalescing them
13071    // adds latency to every one.
13072    let _ = stream.set_nodelay(true);
13073    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13074        .await
13075        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13076    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13077    let mut session_token = None;
13078    if let Some(identity) = identity {
13079        // An enrolled relay answers the announce with a signed-call-home challenge; a
13080        // token-mode relay sends its wire hello instead. Peek to tell them apart
13081        // without consuming either.
13082        let mut magic = [0_u8; 4];
13083        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13084            .await
13085            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13086            .map_err(NodeServerError::CallHomeTransport)?;
13087        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13088            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13089            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13090                .await
13091                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13092                .map_err(NodeServerError::CallHomeTransport)?;
13093            let (answer, token) = identity
13094                .answer_challenge(shared.node_id.as_str(), &frame)
13095                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13096            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13097            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13098            session_token = Some(token);
13099        }
13100    }
13101    serve_connection(stream, shared, session_token, preauth_permit).await
13102}
13103
13104async fn accept_connections_inner(
13105    endpoint: &str,
13106    shared: Arc<NodeShared>,
13107    connections: &mut JoinSet<Result<(), NodeServerError>>,
13108) -> Result<(), NodeServerError> {
13109    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13110    loop {
13111        while connections.try_join_next().is_some() {}
13112        if shared.shutdown.load(Ordering::Acquire) {
13113            return Ok(());
13114        }
13115        let preauth_permit = tokio::select! {
13116            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13117                permit.map_err(|_| NodeServerError::PreauthClosed)?
13118            }
13119            _ = shared.shutdown_notify.notified() => return Ok(()),
13120        };
13121        let server = tokio::select! {
13122            result = listener.accept() => result?,
13123            _ = shared.shutdown_notify.notified() => return Ok(()),
13124        };
13125        let connection_shared = Arc::clone(&shared);
13126        connections.spawn(async move {
13127            serve_connection(server, connection_shared, None, preauth_permit).await
13128        });
13129        if shared.shutdown.load(Ordering::Acquire) {
13130            return Ok(());
13131        }
13132    }
13133}
13134
13135/// Serves one connection, whichever end opened it.
13136///
13137/// Generic over the stream rather than typed to `LocalServerStream`
13138/// because a call-home connection arrives on a `TcpStream` the node itself
13139/// dialled. Nothing in the body ever depended on the concrete type -- it
13140/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13141/// the whole of what welding "who dials" to "who serves" was costing.
13142async fn serve_connection<S>(
13143    mut pipe: S,
13144    shared: Arc<NodeShared>,
13145    session_token: Option<String>,
13146    preauth_permit: OwnedSemaphorePermit,
13147) -> Result<(), NodeServerError>
13148where
13149    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13150{
13151    // A signed call-home connection authenticates with its own per-connection token.
13152    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13153    let hello_frame = timeout(
13154        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13155        read_json_frame_limited_body_timeout(
13156            &mut pipe,
13157            MAX_NODE_HELLO_FRAME_BYTES,
13158            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13159        ),
13160    )
13161    .await
13162    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13163    let ClientFrame::Hello(hello) = hello_frame else {
13164        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13165    };
13166    if hello.build_stamp != BUILD_STAMP {
13167        return Err(NodeServerError::Handshake(format!(
13168            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13169            hello.build_stamp,
13170        )));
13171    }
13172    let compatibility = match hello.compatibility.as_ref() {
13173        Some(offer) => {
13174            let mut selected = node_compatibility_support(&shared)?
13175                .negotiate(offer)
13176                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13177            project_negotiated_provider_ids(&mut selected);
13178            Some(selected)
13179        }
13180        None => None,
13181    };
13182    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13183    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13184        (Some(offer), Some(selected)) => negotiated_auth_proof(
13185            auth_token,
13186            AuthDirection::Server,
13187            hello.role,
13188            &hello.client_nonce,
13189            &server_nonce,
13190            offer,
13191            selected,
13192        ),
13193        (None, None) => auth_proof(
13194            auth_token,
13195            AuthDirection::Server,
13196            hello.role,
13197            &hello.client_nonce,
13198            &server_nonce,
13199        ),
13200        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13201    }
13202    .map_err(NodeServerError::Authentication)?;
13203    write_json_frame_limited(
13204        &mut pipe,
13205        &ServerFrame::Challenge(ServerChallenge {
13206            build_stamp: BUILD_STAMP.to_owned(),
13207            server_nonce,
13208            server_proof,
13209            compatibility: compatibility.clone(),
13210        }),
13211        MAX_NODE_HELLO_FRAME_BYTES,
13212    )
13213    .await?;
13214    let authentication = timeout(
13215        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13216        read_json_frame_limited_body_timeout(
13217            &mut pipe,
13218            MAX_NODE_HELLO_FRAME_BYTES,
13219            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13220        ),
13221    )
13222    .await
13223    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13224    let ClientFrame::Authenticate(authentication) = authentication else {
13225        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13226    };
13227    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13228        (Some(offer), Some(selected)) => negotiated_auth_proof(
13229            auth_token,
13230            AuthDirection::Client,
13231            hello.role,
13232            &hello.client_nonce,
13233            &server_nonce,
13234            offer,
13235            selected,
13236        ),
13237        (None, None) => auth_proof(
13238            auth_token,
13239            AuthDirection::Client,
13240            hello.role,
13241            &hello.client_nonce,
13242            &server_nonce,
13243        ),
13244        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13245    }
13246    .map_err(NodeServerError::Authentication)?;
13247    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13248        return Err(NodeServerError::Handshake("access denied".to_owned()));
13249    }
13250    let selected_capabilities = compatibility
13251        .as_ref()
13252        .map(|selected| selected.capabilities.clone())
13253        .unwrap_or_default();
13254    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13255        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13256    });
13257    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13258        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13259    });
13260    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13261        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13262    });
13263    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13264        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13265    });
13266    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13267        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13268    });
13269    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13270        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13271    }) && selected_capabilities.iter().any(|capability| {
13272        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13273    });
13274    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13275        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13276    });
13277    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13278        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13279    });
13280    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13281        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13282    });
13283    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13284        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13285    });
13286    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13287        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13288    });
13289    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13290        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13291    });
13292    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13293        .try_acquire_owned()
13294        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13295    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13296    let _release_guard = ControllerReleaseGuard {
13297        shared: Arc::clone(&shared),
13298        connection_id,
13299    };
13300    drop(preauth_permit);
13301    let _authenticated_permit = authenticated_permit;
13302    let mut event_rx = shared.event_tx.subscribe();
13303    let mut terminal_event_rx = terminal_event_subscription(
13304        &shared,
13305        include_terminal_frame_events,
13306    );
13307    let mut agent_stream_event_rx = agent_stream_event_subscription(
13308        &shared,
13309        include_agent_stream_events,
13310    );
13311    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13312        &shared,
13313        include_harness_mcp_proxy,
13314    );
13315    write_json_frame(
13316        &mut pipe,
13317        &ServerFrame::Hello(NodeHello {
13318            build_stamp: BUILD_STAMP.to_owned(),
13319            incarnation_id: shared.incarnation_id,
13320            connection_id,
13321            role: hello.role,
13322            event_sequence: shared.current_sequence(),
13323            controller: shared.controller_state(),
13324            snapshot: snapshot_for_wire(
13325                &shared,
13326                include_provider_runtime_status,
13327                include_open_provider_ids,
13328                include_spawn_profiles,
13329                include_managed_worktrees,
13330                include_child_environment_profiles,
13331                include_session_bundles,
13332                include_history_context_packs,
13333                include_agent_progress,
13334                include_session_task_correlation,
13335            ),
13336            compatibility,
13337        }),
13338    )
13339    .await?;
13340    let (mut reader, mut writer) = tokio::io::split(pipe);
13341    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13342    let reader_task = tokio::spawn(async move {
13343        loop {
13344            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13345                &mut reader,
13346                MAX_NODE_CLIENT_FRAME_BYTES,
13347                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13348            )
13349            .await;
13350            let terminal = frame.is_err();
13351            if frame_tx.send(frame).await.is_err() || terminal {
13352                break;
13353            }
13354        }
13355    });
13356    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13357    let mut pending_events = Vec::new();
13358    let mut resync_required = false;
13359    let mut discard_events_through = 0_u64;
13360    loop {
13361        // One tick of the serve loop, whether or not it had anything to do.
13362        // Against `connection_events_sent` this separates "busy" from
13363        // "spinning": a loop turning over thousands of times a second while
13364        // sending nothing is not serving, it is burning a core.
13365        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13366        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13367            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13368        for _ in 0..durable_drain_budget {
13369            match event_rx.try_recv() {
13370                Ok(event) => queue_connection_event(
13371                    &mut pending_events,
13372                    event,
13373                    discard_events_through,
13374                ),
13375                Err(broadcast::error::TryRecvError::Empty) => break,
13376                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13377                    resync_required = true;
13378                    break;
13379                }
13380                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13381            }
13382        }
13383        if let Some(receiver) = terminal_event_rx.as_mut() {
13384            match receiver.try_recv() {
13385                Ok(events) => queue_connection_event_batch(
13386                    &mut pending_events,
13387                    &events,
13388                    discard_events_through,
13389                ),
13390                Err(broadcast::error::TryRecvError::Empty) => {}
13391                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13392                    resync_required = true;
13393                }
13394                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13395            }
13396        }
13397        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13398            // Loops up to the same remaining-room budget the durable
13399            // `event_rx` drain above uses, rather than one `try_recv` per
13400            // tick: each successful receive here is itself only ONE
13401            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13402            // sends `vec![envelope]`, never more), so a single-shot drain
13403            // caps this connection's agent-stream throughput at one chunk
13404            // per outer-loop tick regardless of how many are already queued
13405            // -- exactly the kind of self-inflicted stall this crate's own
13406            // durable-event budget exists to avoid.
13407            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13408                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13409            for _ in 0..agent_stream_drain_budget {
13410                match receiver.try_recv() {
13411                    Ok(events) => queue_connection_event_batch(
13412                        &mut pending_events,
13413                        &events,
13414                        discard_events_through,
13415                    ),
13416                    Err(broadcast::error::TryRecvError::Empty) => break,
13417                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13418                        tracing::warn!(
13419                            connection_id,
13420                            dropped,
13421                            "agent stream event subscription lagged; forcing a resync",
13422                        );
13423                        resync_required = true;
13424                        break;
13425                    }
13426                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13427                }
13428            }
13429        }
13430        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13431            // Mirrors the agent-stream drain immediately above: each
13432            // successful receive is one single-envelope batch
13433            // (`publish_transient` always sends `vec![envelope]`), so this
13434            // loops up to the same remaining-room budget rather than
13435            // stopping after one `try_recv`.
13436            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13437                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13438            for _ in 0..harness_mcp_proxy_drain_budget {
13439                match receiver.try_recv() {
13440                    Ok(events) => queue_connection_event_batch(
13441                        &mut pending_events,
13442                        &events,
13443                        discard_events_through,
13444                    ),
13445                    Err(broadcast::error::TryRecvError::Empty) => break,
13446                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13447                        tracing::warn!(
13448                            connection_id,
13449                            dropped,
13450                            "harness MCP proxy event subscription lagged; forcing a resync",
13451                        );
13452                        resync_required = true;
13453                        break;
13454                    }
13455                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13456                }
13457            }
13458        }
13459        if resync_required {
13460            let event = resync_required_event(&shared);
13461            apply_connection_resync_watermark(
13462                &mut pending_events,
13463                &mut discard_events_through,
13464                event.sequence,
13465            );
13466            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13467            resync_required = false;
13468            continue;
13469        }
13470        if !pending_events.is_empty() {
13471            pending_events.sort_unstable_by_key(|event| event.sequence);
13472            let burst_len = connection_event_burst_len(pending_events.len());
13473            shared
13474                .connection_events_sent
13475                .fetch_add(burst_len as u64, Ordering::Relaxed);
13476            for event in pending_events.drain(..burst_len) {
13477                let event = if include_managed_worktrees {
13478                    Some(event)
13479                } else {
13480                    project_event_without_managed_worktrees(event)
13481                };
13482                let event = event.and_then(|event| if include_open_provider_ids {
13483                    Some(event)
13484                } else {
13485                    project_event_legacy_provider_ids(&shared, event)
13486                });
13487                let event = event.map(|event| if include_child_environment_profiles {
13488                    event
13489                } else {
13490                    project_event_without_child_environment_profile(event)
13491                });
13492                let event = event.map(|event| if include_session_bundles {
13493                    event
13494                } else {
13495                    project_event_without_session_bundle(event)
13496                });
13497                let event = event.map(|event| {
13498                    project_event_history_for_wire(event, include_history_context_packs)
13499                });
13500                let event = event.map(|event| if include_history_context_packs {
13501                    event
13502                } else {
13503                    project_event_without_context_pack(event)
13504                });
13505                let event = event.map(|event| if include_session_task_correlation {
13506                    event
13507                } else {
13508                    project_event_without_session_task_binding(event)
13509                });
13510                let exact_harness_controller = hello.role == ClientRole::Operator
13511                    && shared.controller_state().is_some_and(|controller| {
13512                        controller.connection_id == connection_id
13513                    });
13514                let event = event.and_then(|event| {
13515                    if include_harness_mcp_proxy && exact_harness_controller {
13516                        Some(event)
13517                    } else {
13518                        project_event_without_harness_mcp_proxy(event)
13519                    }
13520                });
13521                if let Some(event) = event {
13522                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13523                }
13524            }
13525        }
13526        tokio::select! {
13527            biased;
13528            frame = frame_rx.recv() => {
13529                let frame = match frame {
13530                    Some(frame) => frame,
13531                    None => break,
13532                };
13533                let frame = match frame {
13534                    Ok(frame) => frame,
13535                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13536                    Err(error) => return Err(error.into()),
13537                };
13538                let ClientFrame::Request(request) = frame else {
13539                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13540                };
13541                #[cfg(feature = "fixture")]
13542                let probe_spawn_managed_worktree_v2 = matches!(
13543                    &request.request,
13544                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13545                );
13546                let requires_open_provider_ids =
13547                    request_requires_open_provider_ids(&shared, &request.request);
13548                let requires_child_environment_profile =
13549                    request_requires_child_environment_profile(&shared, &request.request);
13550                let requires_session_bundle =
13551                    request_requires_session_bundle(&shared, &request.request);
13552                let requires_history_context_pack =
13553                    request_requires_history_context_pack(&shared, &request.request);
13554                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13555                    ResponseEnvelope {
13556                        request_id: request.request_id,
13557                        result: Err(failure(
13558                            NodeFailureCode::UnsupportedCapability,
13559                            "open provider IDs were not negotiated",
13560                        )),
13561                    }
13562                } else if requires_child_environment_profile
13563                    && !include_child_environment_profiles
13564                {
13565                    ResponseEnvelope {
13566                        request_id: request.request_id,
13567                        result: Err(failure(
13568                            NodeFailureCode::UnsupportedCapability,
13569                            "child environment profiles were not negotiated",
13570                        )),
13571                    }
13572                } else if requires_session_bundle && !include_session_bundles {
13573                    ResponseEnvelope {
13574                        request_id: request.request_id,
13575                        result: Err(failure(
13576                            NodeFailureCode::UnsupportedCapability,
13577                            "session bundles were not negotiated",
13578                        )),
13579                    }
13580                } else if requires_history_context_pack && !include_history_context_packs {
13581                    ResponseEnvelope {
13582                        request_id: request.request_id,
13583                        result: Err(failure(
13584                            NodeFailureCode::UnsupportedCapability,
13585                            "history context packs were not negotiated",
13586                        )),
13587                    }
13588                } else if request_uses_unnegotiated_capability(
13589                    &request.request,
13590                    &selected_capabilities,
13591                )
13592                {
13593                    ResponseEnvelope {
13594                        request_id: request.request_id,
13595                        result: Err(failure(
13596                            NodeFailureCode::UnsupportedCapability,
13597                            "request capability was not negotiated",
13598                        )),
13599                    }
13600                } else {
13601                    process_request(&shared, connection_id, hello.role, request).await
13602                };
13603                #[cfg(feature = "fixture")]
13604                if probe_spawn_managed_worktree_v2 {
13605                    shared
13606                        .fixture_spawn_managed_worktree_v2_failure_probe
13607                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13608                }
13609                if !include_provider_runtime_status {
13610                    clear_response_provider_runtime_status(&mut reply);
13611                }
13612                if !include_managed_worktrees {
13613                    project_response_without_managed_worktrees(&mut reply);
13614                }
13615                if !include_open_provider_ids {
13616                    project_response_legacy_provider_ids(&shared, &mut reply);
13617                }
13618                if !include_child_environment_profiles {
13619                    project_response_without_child_environment_profile(&mut reply);
13620                }
13621                if !include_session_bundles {
13622                    project_response_without_session_bundle(&mut reply);
13623                }
13624                project_response_history_for_wire(
13625                    &mut reply,
13626                    include_history_context_packs,
13627                );
13628                if !include_history_context_packs {
13629                    project_response_without_context_pack(&mut reply);
13630                }
13631                if !include_agent_progress {
13632                    project_response_without_agent_progress(&mut reply);
13633                }
13634                if !include_session_task_correlation {
13635                    project_response_without_session_task_binding(&mut reply);
13636                }
13637                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13638            }
13639            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13640            event = event_rx.recv() => {
13641                match event {
13642                    Ok(event) => queue_connection_event(
13643                        &mut pending_events,
13644                        event,
13645                        discard_events_through,
13646                    ),
13647                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13648                    Err(broadcast::error::RecvError::Closed) => break,
13649                }
13650            }
13651            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13652                match events {
13653                    Ok(events) => queue_connection_event_batch(
13654                        &mut pending_events,
13655                        &events,
13656                        discard_events_through,
13657                    ),
13658                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13659                    Err(broadcast::error::RecvError::Closed) => break,
13660                }
13661            }
13662            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13663                match events {
13664                    Ok(events) => queue_connection_event_batch(
13665                        &mut pending_events,
13666                        &events,
13667                        discard_events_through,
13668                    ),
13669                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13670                    Err(broadcast::error::RecvError::Closed) => break,
13671                }
13672            }
13673            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13674                match events {
13675                    Ok(events) => queue_connection_event_batch(
13676                        &mut pending_events,
13677                        &events,
13678                        discard_events_through,
13679                    ),
13680                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13681                    Err(broadcast::error::RecvError::Closed) => break,
13682                }
13683            }
13684            _ = shared.shutdown_notify.notified() => break,
13685        }
13686    }
13687    Ok(())
13688}
13689
13690async fn receive_terminal_event_batch(
13691    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13692) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13693    match receiver {
13694        Some(receiver) => receiver.recv().await,
13695        None => std::future::pending().await,
13696    }
13697}
13698
13699fn terminal_event_subscription(
13700    shared: &NodeShared,
13701    enabled: bool,
13702) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13703    enabled.then(|| shared.terminal_event_tx.subscribe())
13704}
13705
13706/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13707/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13708/// this needs a dedicated channel rather than a flag on the terminal one.
13709fn agent_stream_event_subscription(
13710    shared: &NodeShared,
13711    enabled: bool,
13712) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13713    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13714}
13715
13716/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13717/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13718/// own doc for why this needs a dedicated channel rather than a ride on
13719/// the durable `event_tx`.
13720fn harness_mcp_proxy_event_subscription(
13721    shared: &NodeShared,
13722    enabled: bool,
13723) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13724    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13725}
13726
13727fn queue_connection_event(
13728    pending: &mut Vec<NodeEventEnvelope>,
13729    event: NodeEventEnvelope,
13730    discard_through: u64,
13731) {
13732    if event.sequence <= discard_through
13733        && !event.event.requires_harness_mcp_proxy_capability()
13734    {
13735        return;
13736    }
13737    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13738        if let Some(index) = pending.iter().position(|current| {
13739            matches!(
13740                &current.event,
13741                NodeEvent::TerminalFrame {
13742                    address: current_address,
13743                    ..
13744                } if current_address == address
13745            )
13746        }) {
13747            pending.remove(index);
13748        }
13749    }
13750    pending.push(event);
13751}
13752
13753fn project_event_without_harness_mcp_proxy(
13754    envelope: NodeEventEnvelope,
13755) -> Option<NodeEventEnvelope> {
13756    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13757}
13758
13759fn queue_connection_event_batch(
13760    pending: &mut Vec<NodeEventEnvelope>,
13761    events: &[NodeEventEnvelope],
13762    discard_through: u64,
13763) {
13764    for event in events {
13765        queue_connection_event(pending, event.clone(), discard_through);
13766    }
13767}
13768
13769fn apply_connection_resync_watermark(
13770    pending: &mut Vec<NodeEventEnvelope>,
13771    discard_through: &mut u64,
13772    marker_sequence: u64,
13773) {
13774    *discard_through = (*discard_through).max(marker_sequence);
13775    pending.retain(|event| event.sequence > *discard_through);
13776}
13777
13778fn connection_event_burst_len(pending_len: usize) -> usize {
13779    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13780}
13781
13782fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13783    let history = shared
13784        .history
13785        .lock()
13786        .unwrap_or_else(|poisoned| poisoned.into_inner());
13787    let sequence = history.last_sequence;
13788    let oldest_available_sequence = history.replay_floor_sequence;
13789    NodeEventEnvelope {
13790        sequence,
13791        event: NodeEvent::ResyncRequired {
13792            oldest_available_sequence,
13793        },
13794    }
13795}
13796
13797fn node_compatibility_support(
13798    shared: &NodeShared,
13799) -> Result<NodeCompatibilitySupport, NodeServerError> {
13800    let mut support = node_compatibility_support_for_manifest(
13801        &shared.provider_contracts,
13802        &shared.provider_adapter_contracts,
13803    )?;
13804    if shared
13805        .delivery_store
13806        .lock()
13807        .unwrap_or_else(|poisoned| poisoned.into_inner())
13808        .is_some()
13809    {
13810        support.capabilities.push(
13811            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13812                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13813        );
13814    }
13815    if shared.harness_mcp_registry.is_some() {
13816        support.capabilities.push(
13817            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13818                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13819        );
13820    }
13821    Ok(support)
13822}
13823
13824fn node_compatibility_support_for_manifest(
13825    provider_contracts: &[ProviderContractSupport],
13826    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13827) -> Result<NodeCompatibilitySupport, NodeServerError> {
13828    Ok(NodeCompatibilitySupport {
13829        build_stamp: BUILD_STAMP.to_owned(),
13830        capabilities: baseline_capabilities()?,
13831        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13832        path_semantics: platform::path_semantics(),
13833        local_transport: platform::local_transport(),
13834        state_schema: StateSchemaSupport {
13835            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13836                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13837        },
13838        provider_contracts: provider_contracts.to_vec(),
13839        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13840    })
13841}
13842
13843fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13844    let capabilities = [
13845        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13846        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13847        NODE_REPOSITORY_PATH_CAPABILITY,
13848        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13849        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13850        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13851        NODE_GIT_READ_CAPABILITY,
13852        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13853        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13854        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13855        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13856        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13857        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13858        NODE_ACP_CONTROL_CAPABILITY,
13859        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13860        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13861        NODE_WORKTREE_SELECTION_CAPABILITY,
13862        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13863        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13864        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13865        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13866        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13867        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13868        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13869        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13870        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13871        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13872        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13873        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13874    ]
13875        .into_iter()
13876        .map(|capability| {
13877            CapabilityId::new(capability)
13878                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13879        })
13880        .collect::<Result<Vec<_>, _>>()?;
13881    #[cfg(windows)]
13882    let capabilities = {
13883        let mut capabilities = capabilities;
13884        capabilities.push(
13885            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13886                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13887        );
13888        capabilities
13889    };
13890    Ok(capabilities)
13891}
13892
13893fn request_uses_unnegotiated_capability(
13894    request: &NodeRequest,
13895    selected_capabilities: &[CapabilityId],
13896) -> bool {
13897    let selected = |required: &str| {
13898        selected_capabilities
13899            .iter()
13900            .any(|capability| capability.as_str() == required)
13901    };
13902    request
13903        .required_capability()
13904        .is_some_and(|required| !selected(required))
13905        || (request.requires_worktree_selection_capability()
13906            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13907        || (request.requires_spawn_spec_defaults_overrides_capability()
13908            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13909        || (request.requires_spawn_profile_revision_capability()
13910            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13911        || (request.requires_child_environment_profile_capability()
13912            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13913        || (request.requires_session_bundle_materialization_capability()
13914            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13915        || (request.requires_history_context_pack_capability()
13916            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13917}
13918
13919fn snapshot_for_wire(
13920    shared: &NodeShared,
13921    include_provider_runtime_status: bool,
13922    include_open_provider_ids: bool,
13923    include_spawn_profiles: bool,
13924    include_managed_worktrees: bool,
13925    include_child_environment_profiles: bool,
13926    include_session_bundles: bool,
13927    include_history_context_packs: bool,
13928    include_agent_progress: bool,
13929    include_session_task_correlation: bool,
13930) -> NodeSnapshot {
13931    let mut snapshot = shared.snapshot();
13932    if !include_provider_runtime_status {
13933        snapshot.provider_runtime_statuses.clear();
13934    }
13935    if !include_open_provider_ids {
13936        project_snapshot_legacy_provider_ids(&mut snapshot);
13937    }
13938    if !include_managed_worktrees {
13939        snapshot.managed_worktrees.clear();
13940        for workspace in &mut snapshot.workspaces {
13941            workspace.managed_worktree_profiles = None;
13942        }
13943    }
13944    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13945        if !include_spawn_profiles {
13946            inventory.spawn_profiles = None;
13947        }
13948        if !include_session_bundles {
13949            inventory.bundles = None;
13950        }
13951        if inventory.spawn_profiles.is_none()
13952            && inventory.bundles.is_none()
13953            && inventory.network_allowlists.is_none()
13954        {
13955            snapshot.launch_inventory = None;
13956        }
13957    }
13958    if !include_child_environment_profiles {
13959        clear_snapshot_child_environment_profiles(&mut snapshot);
13960    }
13961    if !include_session_bundles {
13962        clear_snapshot_session_bundles(&mut snapshot);
13963    }
13964    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13965    if !include_history_context_packs {
13966        clear_snapshot_context_packs(&mut snapshot);
13967    }
13968    if !include_agent_progress {
13969        snapshot.agent_progress.clear();
13970    }
13971    if !include_session_task_correlation {
13972        clear_snapshot_session_task_bindings(&mut snapshot);
13973    }
13974    snapshot
13975}
13976
13977fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13978    for record in &mut snapshot.session_records {
13979        record.task_binding = None;
13980    }
13981}
13982
13983fn project_event_without_session_task_binding(
13984    mut envelope: NodeEventEnvelope,
13985) -> NodeEventEnvelope {
13986    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13987        record.task_binding = None;
13988    }
13989    envelope
13990}
13991
13992fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13993    let Ok(response) = reply.result.as_mut() else { return };
13994    match response {
13995        NodeResponse::Snapshot { snapshot, .. } => {
13996            clear_snapshot_session_task_bindings(snapshot);
13997        }
13998        NodeResponse::Resync { snapshot, events, .. } => {
13999            clear_snapshot_session_task_bindings(snapshot);
14000            for event in events {
14001                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14002                    record.task_binding = None;
14003                }
14004            }
14005        }
14006        NodeResponse::SessionRecordUpdated { record }
14007        | NodeResponse::ProviderSessionIndexed { record }
14008        | NodeResponse::NativeSessionIndexed { record, .. }
14009        | NodeResponse::SessionRecordResumed { record, .. } => {
14010            record.task_binding = None;
14011        }
14012        _ => {}
14013    }
14014}
14015
14016fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14017    let Ok(response) = reply.result.as_mut() else { return };
14018    match response {
14019        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14020            snapshot.agent_progress.clear();
14021        }
14022        _ => {}
14023    }
14024}
14025
14026fn project_event_without_managed_worktrees(
14027    mut envelope: NodeEventEnvelope,
14028) -> Option<NodeEventEnvelope> {
14029    if matches!(
14030        envelope.event,
14031        NodeEvent::ManagedWorktreeUpserted { .. }
14032            | NodeEvent::ManagedWorktreeRemoved { .. }
14033    ) {
14034        return None;
14035    }
14036    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14037        workspace.managed_worktree_profiles = None;
14038    }
14039    Some(envelope)
14040}
14041
14042fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14043    if matches!(
14044        reply.result,
14045        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14046            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14047    ) {
14048        reply.result = Err(failure(
14049            NodeFailureCode::UnsupportedCapability,
14050            "managed worktree capability was not negotiated",
14051        ));
14052        return;
14053    }
14054    let Ok(response) = reply.result.as_mut() else { return };
14055    match response {
14056        NodeResponse::Snapshot { snapshot, .. } => {
14057            snapshot.managed_worktrees.clear();
14058            for workspace in &mut snapshot.workspaces {
14059                workspace.managed_worktree_profiles = None;
14060            }
14061        }
14062        NodeResponse::Resync { snapshot, events, .. } => {
14063            snapshot.managed_worktrees.clear();
14064            for workspace in &mut snapshot.workspaces {
14065                workspace.managed_worktree_profiles = None;
14066            }
14067            *events = std::mem::take(events)
14068                .into_iter()
14069                .filter_map(project_event_without_managed_worktrees)
14070                .collect();
14071        }
14072        NodeResponse::WorkspaceRegistered { workspace }
14073        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14074        | NodeResponse::WorktreeCreated { workspace, .. } => {
14075            workspace.managed_worktree_profiles = None;
14076        }
14077        NodeResponse::WorkspaceInspected { inspection } => {
14078            inspection.git.managed_worktree = None;
14079        }
14080        _ => {}
14081    }
14082}
14083
14084fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14085    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14086        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14087    });
14088    if includes_open_provider_ids {
14089        return;
14090    }
14091    compatibility
14092        .provider_contracts
14093        .retain(|contract| provider_id_is_legacy(&contract.provider));
14094    compatibility
14095        .provider_adapter_contracts
14096        .retain(|contract| provider_id_is_legacy(&contract.provider));
14097}
14098
14099fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14100    if let Some(spec) = match request {
14101        NodeRequest::SpawnSpec { spec }
14102        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14103        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14104        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14105        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14106        _ => None,
14107    } {
14108        return shared
14109            .resolve_spawn_spec(spec)
14110            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14111    }
14112    request_requires_open_provider_ids_with(
14113        request,
14114        |session| shared.validate_address(session).ok(),
14115        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14116    )
14117}
14118
14119fn request_requires_child_environment_profile(
14120    shared: &NodeShared,
14121    request: &NodeRequest,
14122) -> bool {
14123    if let Some(spec) = match request {
14124        NodeRequest::SpawnSpec { spec }
14125        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14126        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14127        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14128        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14129        _ => None,
14130    } {
14131        return shared
14132            .resolve_spawn_spec(spec)
14133            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14134    }
14135    match request {
14136        NodeRequest::Resume { session, .. }
14137        | NodeRequest::Prompt { session, .. }
14138        | NodeRequest::Paste { session, .. }
14139        | NodeRequest::Input { session, .. }
14140        | NodeRequest::TerminalBytes { session, .. }
14141        | NodeRequest::TerminalControl { session, .. }
14142        | NodeRequest::Resize { session, .. }
14143        | NodeRequest::Interrupt { session }
14144        | NodeRequest::Stop { session, .. }
14145        | NodeRequest::Remove { session }
14146        | NodeRequest::ResolveInteraction { session, .. }
14147        | NodeRequest::SetSessionMode { session, .. }
14148        | NodeRequest::SetSessionConfigOption { session, .. }
14149        | NodeRequest::SetSessionModel { session, .. }
14150        | NodeRequest::DiscoverHistory { session, .. }
14151        | NodeRequest::LoadHistory { session, .. }
14152        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14153        | NodeRequest::ExportContextPack { session } => shared
14154            .session_bindings
14155            .lock()
14156            .unwrap_or_else(|poisoned| poisoned.into_inner())
14157            .get(&session.session.instance_id)
14158            .filter(|binding| {
14159                binding.workspace_id == session.workspace_id
14160                    && binding.generation == session.session.generation
14161            })
14162            .map_or(true, |binding| binding.environment_profile.is_some()),
14163        NodeRequest::RenameSessionRecord { record_id, .. }
14164        | NodeRequest::SetSessionTask { record_id, .. }
14165        | NodeRequest::ResumeSessionRecord { record_id, .. }
14166        | NodeRequest::ForgetSessionRecord { record_id } => shared
14167            .record(record_id)
14168            .map_or(true, |record| record.environment_profile.is_some()),
14169        NodeRequest::ArmHarnessMcpReservation { .. }
14170        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14171        | NodeRequest::ActivateHarnessMcpReservation { .. }
14172        | NodeRequest::AbortHarnessMcpReservation { .. }
14173        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14174        | NodeRequest::RejectHarnessMcpCall { .. }
14175        | NodeRequest::Snapshot
14176        | NodeRequest::Resync { .. }
14177        | NodeRequest::BeginDeliveryStage { .. }
14178        | NodeRequest::PutDeliveryBlobChunk { .. }
14179        | NodeRequest::CommitDeliveryStage { .. }
14180        | NodeRequest::AbortDeliveryStage { .. }
14181        | NodeRequest::BrowseHostDirectories { .. }
14182        | NodeRequest::InspectWorkspace { .. }
14183        | NodeRequest::ReadWorkspaceFile { .. }
14184        | NodeRequest::WriteWorkspaceFile { .. }
14185        | NodeRequest::CreateWorkspaceFile { .. }
14186        | NodeRequest::CreateWorkspaceDirectory { .. }
14187        | NodeRequest::ReadGitHistory { .. }
14188        | NodeRequest::ReadGitDiff { .. }
14189        | NodeRequest::AcquireController { .. }
14190        | NodeRequest::ReleaseController
14191        | NodeRequest::Spawn { .. }
14192        | NodeRequest::SpawnSpec { .. }
14193        | NodeRequest::RegisterWorkspace { .. }
14194        | NodeRequest::CreateStandaloneWorkspace { .. }
14195        | NodeRequest::UnregisterWorkspace { .. }
14196        | NodeRequest::CreateWorktree { .. }
14197        | NodeRequest::RemoveWorktree { .. }
14198        | NodeRequest::SpawnManagedWorktree { .. }
14199        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14200        | NodeRequest::CleanupManagedWorktree { .. }
14201        | NodeRequest::IndexProviderSession { .. }
14202        | NodeRequest::IndexNativeSession { .. }
14203        | NodeRequest::CatalogNativeSessions { .. }
14204        | NodeRequest::PageNativeSessions { .. }
14205        | NodeRequest::PreviewNativeSession { .. }
14206        | NodeRequest::PreviewSessionRecord { .. }
14207        | NodeRequest::ForgetContextPack { .. }
14208        | NodeRequest::ResolveDurableContextPack { .. }
14209        | NodeRequest::ReadContextPack { .. }
14210        | NodeRequest::Shutdown => false,
14211    }
14212}
14213
14214fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14215    if let Some(spec) = match request {
14216        NodeRequest::SpawnSpec { spec }
14217        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14218        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14219        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14220        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14221        _ => None,
14222    } {
14223        return shared
14224            .resolve_spawn_spec(spec)
14225            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14226    }
14227    match request {
14228        NodeRequest::Resume { session, .. }
14229        | NodeRequest::Prompt { session, .. }
14230        | NodeRequest::Paste { session, .. }
14231        | NodeRequest::Input { session, .. }
14232        | NodeRequest::TerminalBytes { session, .. }
14233        | NodeRequest::TerminalControl { session, .. }
14234        | NodeRequest::Resize { session, .. }
14235        | NodeRequest::Interrupt { session }
14236        | NodeRequest::Stop { session, .. }
14237        | NodeRequest::Remove { session }
14238        | NodeRequest::ResolveInteraction { session, .. }
14239        | NodeRequest::SetSessionMode { session, .. }
14240        | NodeRequest::SetSessionConfigOption { session, .. }
14241        | NodeRequest::SetSessionModel { session, .. }
14242        | NodeRequest::DiscoverHistory { session, .. }
14243        | NodeRequest::LoadHistory { session, .. }
14244        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14245        | NodeRequest::ExportContextPack { session } => shared
14246            .session_bindings
14247            .lock()
14248            .unwrap_or_else(|poisoned| poisoned.into_inner())
14249            .get(&session.session.instance_id)
14250            .filter(|binding| {
14251                binding.workspace_id == session.workspace_id
14252                    && binding.generation == session.session.generation
14253            })
14254            .map_or(true, |binding| binding.bundle.is_some()),
14255        NodeRequest::RenameSessionRecord { record_id, .. }
14256        | NodeRequest::SetSessionTask { record_id, .. }
14257        | NodeRequest::ResumeSessionRecord { record_id, .. }
14258        | NodeRequest::ForgetSessionRecord { record_id } => shared
14259            .record(record_id)
14260            .map_or(true, |record| record.bundle.is_some()),
14261        NodeRequest::ArmHarnessMcpReservation { .. }
14262        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14263        | NodeRequest::ActivateHarnessMcpReservation { .. }
14264        | NodeRequest::AbortHarnessMcpReservation { .. }
14265        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14266        | NodeRequest::RejectHarnessMcpCall { .. }
14267        | NodeRequest::Snapshot
14268        | NodeRequest::Resync { .. }
14269        | NodeRequest::BeginDeliveryStage { .. }
14270        | NodeRequest::PutDeliveryBlobChunk { .. }
14271        | NodeRequest::CommitDeliveryStage { .. }
14272        | NodeRequest::AbortDeliveryStage { .. }
14273        | NodeRequest::BrowseHostDirectories { .. }
14274        | NodeRequest::InspectWorkspace { .. }
14275        | NodeRequest::ReadWorkspaceFile { .. }
14276        | NodeRequest::WriteWorkspaceFile { .. }
14277        | NodeRequest::CreateWorkspaceFile { .. }
14278        | NodeRequest::CreateWorkspaceDirectory { .. }
14279        | NodeRequest::ReadGitHistory { .. }
14280        | NodeRequest::ReadGitDiff { .. }
14281        | NodeRequest::AcquireController { .. }
14282        | NodeRequest::ReleaseController
14283        | NodeRequest::Spawn { .. }
14284        | NodeRequest::SpawnSpec { .. }
14285        | NodeRequest::RegisterWorkspace { .. }
14286        | NodeRequest::CreateStandaloneWorkspace { .. }
14287        | NodeRequest::UnregisterWorkspace { .. }
14288        | NodeRequest::CreateWorktree { .. }
14289        | NodeRequest::RemoveWorktree { .. }
14290        | NodeRequest::SpawnManagedWorktree { .. }
14291        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14292        | NodeRequest::CleanupManagedWorktree { .. }
14293        | NodeRequest::IndexProviderSession { .. }
14294        | NodeRequest::IndexNativeSession { .. }
14295        | NodeRequest::CatalogNativeSessions { .. }
14296        | NodeRequest::PageNativeSessions { .. }
14297        | NodeRequest::PreviewNativeSession { .. }
14298        | NodeRequest::PreviewSessionRecord { .. }
14299        | NodeRequest::ForgetContextPack { .. }
14300        | NodeRequest::ResolveDurableContextPack { .. }
14301        | NodeRequest::ReadContextPack { .. }
14302        | NodeRequest::Shutdown => false,
14303    }
14304}
14305
14306fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14307    if request.requires_history_context_pack_capability() {
14308        return true;
14309    }
14310    if let Some(spec) = match request {
14311        NodeRequest::SpawnSpec { spec }
14312        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14313        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14314        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14315        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14316        _ => None,
14317    } {
14318        return shared
14319            .resolve_spawn_spec(spec)
14320            .map_or(true, |resolved| resolved.context_id.is_some());
14321    }
14322    match request {
14323        NodeRequest::Resume { session, .. }
14324        | NodeRequest::Prompt { session, .. }
14325        | NodeRequest::Paste { session, .. }
14326        | NodeRequest::Input { session, .. }
14327        | NodeRequest::TerminalBytes { session, .. }
14328        | NodeRequest::TerminalControl { session, .. }
14329        | NodeRequest::Resize { session, .. }
14330        | NodeRequest::Interrupt { session }
14331        | NodeRequest::Stop { session, .. }
14332        | NodeRequest::Remove { session }
14333        | NodeRequest::ResolveInteraction { session, .. }
14334        | NodeRequest::SetSessionMode { session, .. }
14335        | NodeRequest::SetSessionConfigOption { session, .. }
14336        | NodeRequest::SetSessionModel { session, .. } => shared
14337            .session_bindings
14338            .lock()
14339            .unwrap_or_else(|poisoned| poisoned.into_inner())
14340            .get(&session.session.instance_id)
14341            .filter(|binding| {
14342                binding.workspace_id == session.workspace_id
14343                    && binding.generation == session.session.generation
14344            })
14345            .map_or(true, |binding| binding.context.is_some()),
14346        NodeRequest::RenameSessionRecord { record_id, .. }
14347        | NodeRequest::SetSessionTask { record_id, .. }
14348        | NodeRequest::ResumeSessionRecord { record_id, .. }
14349        | NodeRequest::ForgetSessionRecord { record_id } => shared
14350            .record(record_id)
14351            .map_or(true, |record| record.context.is_some()),
14352        NodeRequest::ArmHarnessMcpReservation { .. }
14353        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14354        | NodeRequest::ActivateHarnessMcpReservation { .. }
14355        | NodeRequest::AbortHarnessMcpReservation { .. }
14356        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14357        | NodeRequest::RejectHarnessMcpCall { .. }
14358        | NodeRequest::Snapshot
14359        | NodeRequest::Resync { .. }
14360        | NodeRequest::BeginDeliveryStage { .. }
14361        | NodeRequest::PutDeliveryBlobChunk { .. }
14362        | NodeRequest::CommitDeliveryStage { .. }
14363        | NodeRequest::AbortDeliveryStage { .. }
14364        | NodeRequest::BrowseHostDirectories { .. }
14365        | NodeRequest::InspectWorkspace { .. }
14366        | NodeRequest::ReadWorkspaceFile { .. }
14367        | NodeRequest::WriteWorkspaceFile { .. }
14368        | NodeRequest::CreateWorkspaceFile { .. }
14369        | NodeRequest::CreateWorkspaceDirectory { .. }
14370        | NodeRequest::ReadGitHistory { .. }
14371        | NodeRequest::ReadGitDiff { .. }
14372        | NodeRequest::AcquireController { .. }
14373        | NodeRequest::ReleaseController
14374        | NodeRequest::Spawn { .. }
14375        | NodeRequest::SpawnSpec { .. }
14376        | NodeRequest::RegisterWorkspace { .. }
14377        | NodeRequest::CreateStandaloneWorkspace { .. }
14378        | NodeRequest::UnregisterWorkspace { .. }
14379        | NodeRequest::CreateWorktree { .. }
14380        | NodeRequest::RemoveWorktree { .. }
14381        | NodeRequest::SpawnManagedWorktree { .. }
14382        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14383        | NodeRequest::CleanupManagedWorktree { .. }
14384        | NodeRequest::IndexProviderSession { .. }
14385        | NodeRequest::IndexNativeSession { .. }
14386        | NodeRequest::CatalogNativeSessions { .. }
14387        | NodeRequest::PageNativeSessions { .. }
14388        | NodeRequest::PreviewNativeSession { .. }
14389        | NodeRequest::PreviewSessionRecord { .. }
14390        | NodeRequest::DiscoverHistory { .. }
14391        | NodeRequest::LoadHistory { .. }
14392        | NodeRequest::ExportContextPackForSessionRecord { .. }
14393        | NodeRequest::ExportContextPack { .. }
14394        | NodeRequest::ForgetContextPack { .. }
14395        | NodeRequest::ResolveDurableContextPack { .. }
14396        | NodeRequest::ReadContextPack { .. }
14397        | NodeRequest::Shutdown => false,
14398    }
14399}
14400
14401fn request_requires_open_provider_ids_with(
14402    request: &NodeRequest,
14403    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14404    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14405) -> bool {
14406    match request {
14407        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14408        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14409        NodeRequest::CatalogNativeSessions { route, .. }
14410        | NodeRequest::PageNativeSessions { route, .. } => {
14411            !provider_id_is_legacy(&route.provider)
14412        }
14413        NodeRequest::PreviewNativeSession { selection, .. }
14414        | NodeRequest::IndexNativeSession { selection, .. } => {
14415            !provider_id_is_legacy(&selection.route.provider)
14416        }
14417        NodeRequest::SpawnSpec { .. }
14418        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14419        NodeRequest::SpawnManagedWorktree { .. }
14420        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14421        NodeRequest::Resume { session, .. }
14422        | NodeRequest::Prompt { session, .. }
14423        | NodeRequest::Paste { session, .. }
14424        | NodeRequest::Input { session, .. }
14425        | NodeRequest::TerminalBytes { session, .. }
14426        | NodeRequest::TerminalControl { session, .. }
14427        | NodeRequest::Resize { session, .. }
14428        | NodeRequest::Interrupt { session }
14429        | NodeRequest::Stop { session, .. }
14430        | NodeRequest::Remove { session }
14431        | NodeRequest::ResolveInteraction { session, .. }
14432        | NodeRequest::SetSessionMode { session, .. }
14433        | NodeRequest::SetSessionConfigOption { session, .. }
14434        | NodeRequest::SetSessionModel { session, .. }
14435        | NodeRequest::DiscoverHistory { session, .. }
14436        | NodeRequest::LoadHistory { session, .. }
14437        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14438        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14439            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14440        NodeRequest::RenameSessionRecord { record_id, .. }
14441        | NodeRequest::SetSessionTask { record_id, .. }
14442        | NodeRequest::ResumeSessionRecord { record_id, .. }
14443        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14444            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14445        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14446            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14447        NodeRequest::ArmHarnessMcpReservation { .. }
14448        | NodeRequest::ActivateHarnessMcpReservation { .. }
14449        | NodeRequest::AbortHarnessMcpReservation { .. }
14450        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14451        | NodeRequest::RejectHarnessMcpCall { .. }
14452        | NodeRequest::Snapshot
14453        | NodeRequest::Resync { .. }
14454        | NodeRequest::BeginDeliveryStage { .. }
14455        | NodeRequest::PutDeliveryBlobChunk { .. }
14456        | NodeRequest::CommitDeliveryStage { .. }
14457        | NodeRequest::AbortDeliveryStage { .. }
14458        | NodeRequest::BrowseHostDirectories { .. }
14459        | NodeRequest::InspectWorkspace { .. }
14460        | NodeRequest::ReadWorkspaceFile { .. }
14461        | NodeRequest::WriteWorkspaceFile { .. }
14462        | NodeRequest::CreateWorkspaceFile { .. }
14463        | NodeRequest::CreateWorkspaceDirectory { .. }
14464        | NodeRequest::ReadGitHistory { .. }
14465        | NodeRequest::ReadGitDiff { .. }
14466        | NodeRequest::AcquireController { .. }
14467        | NodeRequest::ReleaseController
14468        | NodeRequest::RegisterWorkspace { .. }
14469        | NodeRequest::CreateStandaloneWorkspace { .. }
14470        | NodeRequest::UnregisterWorkspace { .. }
14471        | NodeRequest::CreateWorktree { .. }
14472        | NodeRequest::RemoveWorktree { .. }
14473        | NodeRequest::CleanupManagedWorktree { .. }
14474        | NodeRequest::Shutdown => false,
14475        NodeRequest::ForgetContextPack { .. } => true,
14476        // No session/record to cheaply resolve a provider from here either;
14477        // the resolved receipt can carry an open-id provider, so require the
14478        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14479        // is the same digest-keyed lookup one step further into the same
14480        // catalog, so it inherits the same conservative answer.
14481        NodeRequest::ResolveDurableContextPack { .. }
14482        | NodeRequest::ReadContextPack { .. } => true,
14483    }
14484}
14485
14486fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14487    snapshot.enabled_providers.retain(provider_id_is_legacy);
14488    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14489        snapshot
14490            .provider_runtime_statuses
14491            .as_slice()
14492            .iter()
14493            .filter(|status| provider_id_is_legacy(status.provider()))
14494            .cloned(),
14495    )
14496    .expect("a filtered provider runtime projection remains valid");
14497    snapshot
14498        .session_records
14499        .retain(|record| provider_id_is_legacy(&record.provider));
14500    for workspace in &mut snapshot.workspaces {
14501        workspace
14502            .sessions
14503            .retain(|session| provider_id_is_legacy(&session.agent_id));
14504    }
14505}
14506
14507fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14508    for record in &mut snapshot.session_records {
14509        record.environment_profile = None;
14510    }
14511    if let Some(profiles) = snapshot
14512        .launch_inventory
14513        .as_mut()
14514        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14515    {
14516        for profile in profiles {
14517            profile.environment_profile = None;
14518        }
14519    }
14520}
14521
14522fn project_event_without_child_environment_profile(
14523    mut envelope: NodeEventEnvelope,
14524) -> NodeEventEnvelope {
14525    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14526        record.environment_profile = None;
14527    }
14528    envelope
14529}
14530
14531fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14532    let contains_environment_profile = match reply.result.as_ref() {
14533        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14534            receipt.environment_profile.is_some()
14535        }
14536        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14537            receipt.spawn.environment_profile.is_some()
14538        }
14539        _ => false,
14540    };
14541    if contains_environment_profile {
14542        reply.result = Err(failure(
14543            NodeFailureCode::UnsupportedCapability,
14544            "child environment profiles were not negotiated",
14545        ));
14546        return;
14547    }
14548    let Ok(response) = reply.result.as_mut() else {
14549        return;
14550    };
14551    match response {
14552        NodeResponse::Snapshot { snapshot, .. } => {
14553            clear_snapshot_child_environment_profiles(snapshot);
14554        }
14555        NodeResponse::Resync { snapshot, events, .. } => {
14556            clear_snapshot_child_environment_profiles(snapshot);
14557            for event in events {
14558                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14559                    record.environment_profile = None;
14560                }
14561            }
14562        }
14563        NodeResponse::SessionRecordUpdated { record }
14564        | NodeResponse::ProviderSessionIndexed { record }
14565        | NodeResponse::NativeSessionIndexed { record, .. }
14566        | NodeResponse::SessionRecordResumed { record, .. } => {
14567            record.environment_profile = None;
14568        }
14569        NodeResponse::Armed { .. }
14570        | NodeResponse::Spawned { .. }
14571        | NodeResponse::Activated { .. }
14572        | NodeResponse::Aborted { .. }
14573        | NodeResponse::ReplyChunkAccepted { .. }
14574        | NodeResponse::CallRejected { .. }
14575        | NodeResponse::DeliveryStageBegun { .. }
14576        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14577        | NodeResponse::DeliveryCommitted { .. }
14578        | NodeResponse::DeliveryStageAborted { .. }
14579        | NodeResponse::WorkspaceInspected { .. }
14580        | NodeResponse::HostDirectoriesBrowsed { .. }
14581        | NodeResponse::WorkspaceFileRead { .. }
14582        | NodeResponse::WorkspaceFileWritten { .. }
14583        | NodeResponse::WorkspaceFileCreated { .. }
14584        | NodeResponse::WorkspaceDirectoryCreated { .. }
14585        | NodeResponse::GitHistoryRead { .. }
14586        | NodeResponse::GitDiffRead { .. }
14587        | NodeResponse::Controller { .. }
14588        | NodeResponse::SpawnAccepted { .. }
14589        | NodeResponse::SpawnSpecAccepted { .. }
14590        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14591        | NodeResponse::ManagedWorktreeCleanup { .. }
14592        | NodeResponse::NativeSessionsCataloged { .. }
14593        | NodeResponse::NativeSessionsPaged { .. }
14594        | NodeResponse::NativeSessionPreviewed { .. }
14595        | NodeResponse::SessionRecordPreviewed { .. }
14596        | NodeResponse::HistoryDiscovered { .. }
14597        | NodeResponse::HistoryLoaded { .. }
14598        | NodeResponse::ContextPackForSessionRecordExported { .. }
14599        | NodeResponse::ContextPackExported { .. }
14600        | NodeResponse::ContextPackForgotten { .. }
14601        | NodeResponse::DurableContextPackResolved { .. }
14602        | NodeResponse::ContextPackBytesRead { .. }
14603        | NodeResponse::SessionRecordForgotten { .. }
14604        | NodeResponse::WorkspaceRegistered { .. }
14605        | NodeResponse::StandaloneWorkspaceCreated { .. }
14606        | NodeResponse::WorkspaceUnregistered { .. }
14607        | NodeResponse::WorktreeCreated { .. }
14608        | NodeResponse::WorktreeRemoved { .. }
14609        | NodeResponse::Accepted
14610        | NodeResponse::ShuttingDown => {}
14611    }
14612}
14613
14614fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14615    for record in &mut snapshot.session_records {
14616        record.bundle = None;
14617    }
14618}
14619
14620fn project_event_without_session_bundle(
14621    mut envelope: NodeEventEnvelope,
14622) -> NodeEventEnvelope {
14623    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14624        record.bundle = None;
14625    }
14626    envelope
14627}
14628
14629fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14630    let contains_bundle = match reply.result.as_ref() {
14631        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14632        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14633            receipt.spawn.bundle.is_some()
14634        }
14635        _ => false,
14636    };
14637    if contains_bundle {
14638        reply.result = Err(failure(
14639            NodeFailureCode::UnsupportedCapability,
14640            "session bundles were not negotiated",
14641        ));
14642        return;
14643    }
14644    let Ok(response) = reply.result.as_mut() else {
14645        return;
14646    };
14647    match response {
14648        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14649        NodeResponse::Resync { snapshot, events, .. } => {
14650            clear_snapshot_session_bundles(snapshot);
14651            for event in events {
14652                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14653                    record.bundle = None;
14654                }
14655            }
14656        }
14657        NodeResponse::SessionRecordUpdated { record }
14658        | NodeResponse::ProviderSessionIndexed { record }
14659        | NodeResponse::NativeSessionIndexed { record, .. }
14660        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14661        NodeResponse::Armed { .. }
14662        | NodeResponse::Spawned { .. }
14663        | NodeResponse::Activated { .. }
14664        | NodeResponse::Aborted { .. }
14665        | NodeResponse::ReplyChunkAccepted { .. }
14666        | NodeResponse::CallRejected { .. }
14667        | NodeResponse::DeliveryStageBegun { .. }
14668        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14669        | NodeResponse::DeliveryCommitted { .. }
14670        | NodeResponse::DeliveryStageAborted { .. }
14671        | NodeResponse::WorkspaceInspected { .. }
14672        | NodeResponse::HostDirectoriesBrowsed { .. }
14673        | NodeResponse::WorkspaceFileRead { .. }
14674        | NodeResponse::WorkspaceFileWritten { .. }
14675        | NodeResponse::WorkspaceFileCreated { .. }
14676        | NodeResponse::WorkspaceDirectoryCreated { .. }
14677        | NodeResponse::GitHistoryRead { .. }
14678        | NodeResponse::GitDiffRead { .. }
14679        | NodeResponse::Controller { .. }
14680        | NodeResponse::SpawnAccepted { .. }
14681        | NodeResponse::SpawnSpecAccepted { .. }
14682        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14683        | NodeResponse::ManagedWorktreeCleanup { .. }
14684        | NodeResponse::NativeSessionsCataloged { .. }
14685        | NodeResponse::NativeSessionsPaged { .. }
14686        | NodeResponse::NativeSessionPreviewed { .. }
14687        | NodeResponse::SessionRecordPreviewed { .. }
14688        | NodeResponse::HistoryDiscovered { .. }
14689        | NodeResponse::HistoryLoaded { .. }
14690        | NodeResponse::ContextPackForSessionRecordExported { .. }
14691        | NodeResponse::ContextPackExported { .. }
14692        | NodeResponse::ContextPackForgotten { .. }
14693        | NodeResponse::DurableContextPackResolved { .. }
14694        | NodeResponse::ContextPackBytesRead { .. }
14695        | NodeResponse::SessionRecordForgotten { .. }
14696        | NodeResponse::WorkspaceRegistered { .. }
14697        | NodeResponse::StandaloneWorkspaceCreated { .. }
14698        | NodeResponse::WorkspaceUnregistered { .. }
14699        | NodeResponse::WorktreeCreated { .. }
14700        | NodeResponse::WorktreeRemoved { .. }
14701        | NodeResponse::Accepted
14702        | NodeResponse::ShuttingDown => {}
14703    }
14704}
14705
14706fn project_snapshot_history_for_wire(
14707    snapshot: &mut NodeSnapshot,
14708    include_history_metadata: bool,
14709) {
14710    for workspace in &mut snapshot.workspaces {
14711        for session in &mut workspace.sessions {
14712            if include_history_metadata {
14713                session.history.loaded = None;
14714            } else {
14715                session.history = Default::default();
14716            }
14717        }
14718    }
14719}
14720
14721fn project_event_history_for_wire(
14722    mut envelope: NodeEventEnvelope,
14723    include_history_metadata: bool,
14724) -> NodeEventEnvelope {
14725    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14726        for session in &mut workspace.sessions {
14727            if include_history_metadata {
14728                session.history.loaded = None;
14729            } else {
14730                session.history = Default::default();
14731            }
14732        }
14733    }
14734    envelope
14735}
14736
14737fn project_response_history_for_wire(
14738    reply: &mut ResponseEnvelope,
14739    include_history_metadata: bool,
14740) {
14741    let Ok(response) = reply.result.as_mut() else {
14742        return;
14743    };
14744    match response {
14745        NodeResponse::Snapshot { snapshot, .. } => {
14746            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14747        }
14748        NodeResponse::Resync {
14749            snapshot, events, ..
14750        } => {
14751            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14752            for event in events {
14753                *event = project_event_history_for_wire(
14754                    event.clone(),
14755                    include_history_metadata,
14756                );
14757            }
14758        }
14759        NodeResponse::WorkspaceRegistered { workspace }
14760        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14761        | NodeResponse::WorktreeCreated { workspace, .. } => {
14762            for session in &mut workspace.sessions {
14763                if include_history_metadata {
14764                    session.history.loaded = None;
14765                } else {
14766                    session.history = Default::default();
14767                }
14768            }
14769        }
14770        _ => {}
14771    }
14772}
14773
14774fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14775    for record in &mut snapshot.session_records {
14776        record.context_id = None;
14777        record.context = None;
14778        record.exported_context = None;
14779    }
14780}
14781
14782fn project_event_without_context_pack(
14783    mut envelope: NodeEventEnvelope,
14784) -> NodeEventEnvelope {
14785    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14786        record.context_id = None;
14787        record.context = None;
14788        record.exported_context = None;
14789    }
14790    envelope
14791}
14792
14793fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14794    let contains_context = match reply.result.as_ref() {
14795        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14796        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14797            receipt.spawn.context.is_some()
14798        }
14799        Ok(NodeResponse::HistoryDiscovered { .. })
14800        | Ok(NodeResponse::HistoryLoaded { .. })
14801        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14802        | Ok(NodeResponse::ContextPackExported { .. })
14803        | Ok(NodeResponse::ContextPackForgotten { .. })
14804        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14805        _ => false,
14806    };
14807    if contains_context {
14808        reply.result = Err(failure(
14809            NodeFailureCode::UnsupportedCapability,
14810            "history context packs were not negotiated",
14811        ));
14812        return;
14813    }
14814    let Ok(response) = reply.result.as_mut() else {
14815        return;
14816    };
14817    match response {
14818        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14819        NodeResponse::Resync {
14820            snapshot, events, ..
14821        } => {
14822            clear_snapshot_context_packs(snapshot);
14823            for event in events {
14824                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14825                    record.context_id = None;
14826                    record.context = None;
14827                    record.exported_context = None;
14828                }
14829            }
14830        }
14831        NodeResponse::SessionRecordUpdated { record }
14832        | NodeResponse::ProviderSessionIndexed { record }
14833        | NodeResponse::NativeSessionIndexed { record, .. }
14834        | NodeResponse::SessionRecordResumed { record, .. } => {
14835            record.context_id = None;
14836            record.context = None;
14837            record.exported_context = None;
14838        }
14839        _ => {}
14840    }
14841}
14842
14843fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14844    let contains_open_record = match reply.result.as_ref() {
14845        Ok(NodeResponse::SessionRecordUpdated { record })
14846        | Ok(NodeResponse::ProviderSessionIndexed { record })
14847        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14848            !provider_id_is_legacy(&record.provider)
14849        }
14850        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14851            !provider_id_is_legacy(&selection.route.provider)
14852                || !provider_id_is_legacy(&record.provider)
14853        }
14854        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14855            !provider_id_is_legacy(&receipt.provider)
14856        }
14857        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14858            !provider_id_is_legacy(&receipt.spawn.provider)
14859        }
14860        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14861        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14862            !provider_id_is_legacy(&route.provider)
14863        }
14864        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14865            !provider_id_is_legacy(&selection.route.provider)
14866        }
14867        _ => false,
14868    };
14869    if contains_open_record {
14870        reply.result = Err(failure(
14871            NodeFailureCode::UnsupportedCapability,
14872            "open provider IDs were not negotiated",
14873        ));
14874        return;
14875    }
14876    let Ok(response) = reply.result.as_mut() else {
14877        return;
14878    };
14879    match response {
14880        NodeResponse::Snapshot { snapshot, .. } => {
14881            project_snapshot_legacy_provider_ids(snapshot);
14882        }
14883        NodeResponse::Resync { snapshot, events, .. } => {
14884            project_snapshot_legacy_provider_ids(snapshot);
14885            *events = std::mem::take(events)
14886                .into_iter()
14887                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14888                .collect();
14889        }
14890        NodeResponse::WorkspaceRegistered { workspace }
14891        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14892        | NodeResponse::WorktreeCreated { workspace, .. } => {
14893            workspace
14894                .sessions
14895                .retain(|session| provider_id_is_legacy(&session.agent_id));
14896        }
14897        NodeResponse::Armed { .. }
14898        | NodeResponse::Spawned { .. }
14899        | NodeResponse::Activated { .. }
14900        | NodeResponse::Aborted { .. }
14901        | NodeResponse::ReplyChunkAccepted { .. }
14902        | NodeResponse::CallRejected { .. }
14903        | NodeResponse::DeliveryStageBegun { .. }
14904        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14905        | NodeResponse::DeliveryCommitted { .. }
14906        | NodeResponse::DeliveryStageAborted { .. }
14907        | NodeResponse::WorkspaceInspected { .. }
14908        | NodeResponse::HostDirectoriesBrowsed { .. }
14909        | NodeResponse::WorkspaceFileRead { .. }
14910        | NodeResponse::WorkspaceFileWritten { .. }
14911        | NodeResponse::WorkspaceFileCreated { .. }
14912        | NodeResponse::WorkspaceDirectoryCreated { .. }
14913        | NodeResponse::GitHistoryRead { .. }
14914        | NodeResponse::GitDiffRead { .. }
14915        | NodeResponse::Controller { .. }
14916        | NodeResponse::SpawnAccepted { .. }
14917        | NodeResponse::SpawnSpecAccepted { .. }
14918        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14919        | NodeResponse::ManagedWorktreeCleanup { .. }
14920        | NodeResponse::NativeSessionsCataloged { .. }
14921        | NodeResponse::NativeSessionsPaged { .. }
14922        | NodeResponse::NativeSessionPreviewed { .. }
14923        | NodeResponse::SessionRecordPreviewed { .. }
14924        | NodeResponse::HistoryDiscovered { .. }
14925        | NodeResponse::HistoryLoaded { .. }
14926        | NodeResponse::ContextPackForSessionRecordExported { .. }
14927        | NodeResponse::ContextPackExported { .. }
14928        | NodeResponse::ContextPackForgotten { .. }
14929        | NodeResponse::DurableContextPackResolved { .. }
14930        | NodeResponse::ContextPackBytesRead { .. }
14931        | NodeResponse::SessionRecordUpdated { .. }
14932        | NodeResponse::ProviderSessionIndexed { .. }
14933        | NodeResponse::NativeSessionIndexed { .. }
14934        | NodeResponse::SessionRecordResumed { .. }
14935        | NodeResponse::SessionRecordForgotten { .. }
14936        | NodeResponse::WorkspaceUnregistered { .. }
14937        | NodeResponse::WorktreeRemoved { .. }
14938        | NodeResponse::Accepted
14939        | NodeResponse::ShuttingDown => {}
14940    }
14941}
14942
14943fn project_event_legacy_provider_ids(
14944    shared: &NodeShared,
14945    mut envelope: NodeEventEnvelope,
14946) -> Option<NodeEventEnvelope> {
14947    let include = match &mut envelope.event {
14948        NodeEvent::HarnessMcpReadCall { .. } => true,
14949        NodeEvent::Control { address, .. }
14950        | NodeEvent::AgentStream { address, .. }
14951        | NodeEvent::TerminalFrame { address, .. } => shared
14952            .validate_address(address)
14953            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14954        NodeEvent::WorkspaceAdded { workspace } => {
14955            workspace
14956                .sessions
14957                .retain(|session| provider_id_is_legacy(&session.agent_id));
14958            true
14959        }
14960        NodeEvent::SessionRecordUpserted { record } => {
14961            provider_id_is_legacy(&record.provider)
14962        }
14963        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14964        NodeEvent::SessionRecordRemoved { .. } => shared
14965            .history
14966            .lock()
14967            .unwrap_or_else(|poisoned| poisoned.into_inner())
14968            .removed_record_providers
14969            .get(&envelope.sequence)
14970            .is_some_and(provider_id_is_legacy),
14971        NodeEvent::ControllerChanged { .. }
14972        | NodeEvent::ManagedWorktreeUpserted { .. }
14973        | NodeEvent::ManagedWorktreeRemoved { .. }
14974        | NodeEvent::WorkspaceRemoved { .. }
14975        | NodeEvent::ResyncRequired { .. } => true,
14976    };
14977    include.then_some(envelope)
14978}
14979
14980fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14981    let Ok(response) = reply.result.as_mut() else {
14982        return;
14983    };
14984    match response {
14985        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14986            snapshot.provider_runtime_statuses.clear();
14987        }
14988        NodeResponse::Armed { .. }
14989        | NodeResponse::Spawned { .. }
14990        | NodeResponse::Activated { .. }
14991        | NodeResponse::Aborted { .. }
14992        | NodeResponse::ReplyChunkAccepted { .. }
14993        | NodeResponse::CallRejected { .. }
14994        | NodeResponse::DeliveryStageBegun { .. }
14995        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14996        | NodeResponse::DeliveryCommitted { .. }
14997        | NodeResponse::DeliveryStageAborted { .. }
14998        | NodeResponse::WorkspaceInspected { .. }
14999        | NodeResponse::HostDirectoriesBrowsed { .. }
15000        | NodeResponse::WorkspaceFileRead { .. }
15001        | NodeResponse::WorkspaceFileWritten { .. }
15002        | NodeResponse::WorkspaceFileCreated { .. }
15003        | NodeResponse::WorkspaceDirectoryCreated { .. }
15004        | NodeResponse::GitHistoryRead { .. }
15005        | NodeResponse::GitDiffRead { .. }
15006        | NodeResponse::Controller { .. }
15007        | NodeResponse::SpawnAccepted { .. }
15008        | NodeResponse::SpawnSpecAccepted { .. }
15009        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15010        | NodeResponse::ManagedWorktreeCleanup { .. }
15011        | NodeResponse::NativeSessionsCataloged { .. }
15012        | NodeResponse::NativeSessionsPaged { .. }
15013        | NodeResponse::NativeSessionPreviewed { .. }
15014        | NodeResponse::SessionRecordPreviewed { .. }
15015        | NodeResponse::HistoryDiscovered { .. }
15016        | NodeResponse::HistoryLoaded { .. }
15017        | NodeResponse::ContextPackForSessionRecordExported { .. }
15018        | NodeResponse::ContextPackExported { .. }
15019        | NodeResponse::ContextPackForgotten { .. }
15020        | NodeResponse::DurableContextPackResolved { .. }
15021        | NodeResponse::ContextPackBytesRead { .. }
15022        | NodeResponse::SessionRecordUpdated { .. }
15023        | NodeResponse::ProviderSessionIndexed { .. }
15024        | NodeResponse::NativeSessionIndexed { .. }
15025        | NodeResponse::SessionRecordResumed { .. }
15026        | NodeResponse::SessionRecordForgotten { .. }
15027        | NodeResponse::WorkspaceRegistered { .. }
15028        | NodeResponse::StandaloneWorkspaceCreated { .. }
15029        | NodeResponse::WorkspaceUnregistered { .. }
15030        | NodeResponse::WorktreeCreated { .. }
15031        | NodeResponse::WorktreeRemoved { .. }
15032        | NodeResponse::Accepted
15033        | NodeResponse::ShuttingDown => {}
15034    }
15035}
15036
15037struct ControllerReleaseGuard {
15038    shared: Arc<NodeShared>,
15039    connection_id: u64,
15040}
15041
15042impl Drop for ControllerReleaseGuard {
15043    fn drop(&mut self) {
15044        self.shared.release_controller(self.connection_id);
15045    }
15046}
15047
15048struct AbortTaskOnDrop(AbortHandle);
15049
15050impl Drop for AbortTaskOnDrop {
15051    fn drop(&mut self) {
15052        self.0.abort();
15053    }
15054}
15055
15056/// Reads an unsigned env-config override, following the pattern of every
15057/// other bounded knob in this module: missing/unparsable/zero falls back to
15058/// `default`, and the resolved value is always clamped to `max` so a bad
15059/// override cannot reopen a bound this budget exists to enforce.
15060fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15061    std::env::var(name)
15062        .ok()
15063        .and_then(|value| value.parse::<u64>().ok())
15064        .filter(|value| *value > 0)
15065        .unwrap_or(default)
15066        .min(max)
15067}
15068
15069fn workspace_inspection_time_budget_ms() -> u64 {
15070    env_config_bounded(
15071        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15072        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15073        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15074    )
15075}
15076
15077fn workspace_inspection_entry_cap() -> usize {
15078    env_config_bounded(
15079        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15080        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15081        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15082    ) as usize
15083}
15084
15085/// Tracks the shared walk-side budget across the whole recursive walk: a
15086/// wall-clock deadline (shared with the git phase that runs after the walk)
15087/// and a cap on directory entries visited, whether or not they were pushed
15088/// to the response. Cheap to check per entry — one `Instant::now()` and one
15089/// integer compare.
15090struct WorkspaceWalkBudget {
15091    deadline: Instant,
15092    entry_cap: usize,
15093    entries_visited: u64,
15094    time_budget_exceeded: bool,
15095    entry_cap_exceeded: bool,
15096}
15097
15098impl WorkspaceWalkBudget {
15099    fn new(deadline: Instant, entry_cap: usize) -> Self {
15100        Self {
15101            deadline,
15102            entry_cap,
15103            entries_visited: 0,
15104            time_budget_exceeded: false,
15105            entry_cap_exceeded: false,
15106        }
15107    }
15108
15109    fn is_exhausted(&self) -> bool {
15110        self.time_budget_exceeded || self.entry_cap_exceeded
15111    }
15112
15113    /// Records one visited directory entry (pushed to the response or not)
15114    /// and reports whether the walk must stop now.
15115    fn record_visit(&mut self) -> bool {
15116        self.entries_visited += 1;
15117        if self.entries_visited as usize >= self.entry_cap {
15118            self.entry_cap_exceeded = true;
15119        }
15120        if Instant::now() >= self.deadline {
15121            self.time_budget_exceeded = true;
15122        }
15123        self.is_exhausted()
15124    }
15125}
15126
15127fn collect_workspace_entries(
15128    root: &Path,
15129    deadline: Instant,
15130    entry_cap: usize,
15131) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15132    let mut entries = Vec::new();
15133    let mut truncated = false;
15134    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15135    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15136    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15137    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15138    // require strict global byte order over relative paths.
15139    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15140    (entries, truncated, budget)
15141}
15142
15143/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15144/// `inspect_workspace` response — `None` once the walk and git phase both
15145/// completed inside their shared budget.
15146fn workspace_inspection_truncation(
15147    walk_budget: &WorkspaceWalkBudget,
15148    git_time_budget_exceeded: bool,
15149    elapsed: Duration,
15150) -> Option<WorkspaceInspectionTruncationV1> {
15151    if !walk_budget.time_budget_exceeded
15152        && !walk_budget.entry_cap_exceeded
15153        && !git_time_budget_exceeded
15154    {
15155        return None;
15156    }
15157    Some(WorkspaceInspectionTruncationV1 {
15158        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15159        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15160        git_time_budget_exceeded,
15161        entries_visited: walk_budget.entries_visited,
15162        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15163    })
15164}
15165
15166fn walk_workspace_directory(
15167    directory: &Path,
15168    relative_directory: Option<&RepositoryPath>,
15169    depth: usize,
15170    entries: &mut Vec<WorkspaceEntry>,
15171    truncated: &mut bool,
15172    budget: &mut WorkspaceWalkBudget,
15173) {
15174    if budget.is_exhausted() {
15175        *truncated = true;
15176        return;
15177    }
15178    let read_dir = match std::fs::read_dir(directory) {
15179        Ok(read_dir) => read_dir,
15180        Err(_) => {
15181            *truncated = true;
15182            return;
15183        }
15184    };
15185    let mut children = Vec::new();
15186    for child in read_dir {
15187        match child {
15188            Ok(child) => children.push(child),
15189            Err(_) => *truncated = true,
15190        }
15191    }
15192    children.sort_by_key(|child| child.file_name());
15193    for child in children {
15194        if budget.record_visit() {
15195            *truncated = true;
15196            return;
15197        }
15198        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15199            *truncated = true;
15200            return;
15201        }
15202        let file_type = match child.file_type() {
15203            Ok(file_type) => file_type,
15204            Err(_) => {
15205                *truncated = true;
15206                continue;
15207            }
15208        };
15209        if file_type.is_symlink() {
15210            continue;
15211        }
15212        let name = match child.file_name().into_string() {
15213            Ok(name) => name,
15214            Err(_) => {
15215                *truncated = true;
15216                continue;
15217            }
15218        };
15219        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15220            continue;
15221        }
15222        let relative_path = match relative_directory {
15223            None => windows_repository_path(name),
15224            Some(parent) => parent
15225                .as_utf8()
15226                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15227        };
15228        let Some(relative_path) = relative_path else {
15229            *truncated = true;
15230            continue;
15231        };
15232        let kind = if file_type.is_dir() {
15233            WorkspaceEntryKind::Directory
15234        } else if file_type.is_file() {
15235            WorkspaceEntryKind::File
15236        } else {
15237            continue;
15238        };
15239        entries.push(WorkspaceEntry {
15240            relative_path: relative_path.clone(),
15241            kind,
15242        });
15243        if file_type.is_dir() {
15244            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15245                walk_workspace_directory(
15246                    &child.path(),
15247                    Some(&relative_path),
15248                    depth + 1,
15249                    entries,
15250                    truncated,
15251                    budget,
15252                );
15253            } else {
15254                *truncated = true;
15255            }
15256        }
15257    }
15258}
15259
15260fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15261    if value.contains('\\') {
15262        return None;
15263    }
15264    RepositoryPath::utf8(value).ok()
15265}
15266
15267fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15268    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15269}
15270
15271/// Excludes a directory from the workspace tree walk. The three literal
15272/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15273/// beneath them catches every build-output directory regardless of name
15274/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15275/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15276/// directory without maintaining a name heuristic list). The name check
15277/// runs first and returns early so a directory rejected by name never pays
15278/// for the extra `CACHEDIR.TAG` stat.
15279fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15280    if name.eq_ignore_ascii_case(".git")
15281        || name.eq_ignore_ascii_case("target")
15282        || name.eq_ignore_ascii_case("node_modules")
15283    {
15284        return true;
15285    }
15286    path.join("CACHEDIR.TAG").is_file()
15287}
15288
15289fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15290    matches!(
15291        status,
15292        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15293    )
15294}
15295
15296#[derive(Clone, Debug, Eq, PartialEq)]
15297enum ContextPackRepositoryHead {
15298    NotRepository,
15299    Unborn,
15300    Commit(String),
15301}
15302
15303#[derive(Clone, Debug, Eq, PartialEq)]
15304struct ContextPackRepositoryObservation {
15305    head: ContextPackRepositoryHead,
15306    git: GitSnapshot,
15307    files: Vec<ContextPackRepositoryFileSource>,
15308}
15309
15310fn stable_context_pack_repository(
15311    first: ContextPackRepositoryObservation,
15312    second: ContextPackRepositoryObservation,
15313) -> Result<ContextPackRepository, NodeFailure> {
15314    if first != second {
15315        return Err(failure(
15316            NodeFailureCode::ContextPackMaterializationFailed,
15317            "context repository changed during bounded capture",
15318        ));
15319    }
15320    Ok(ContextPackRepository::from_sources(
15321        &second.git,
15322        second.files,
15323    ))
15324}
15325
15326/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15327/// (and only once) the shared walk+git `deadline` has already passed. Every
15328/// remaining phase in `inspect_git_workspace` checks this immediately
15329/// before issuing its own git call, so a budget spent by the walk (or by an
15330/// earlier git call) skips the rest of the chain instead of paying for it
15331/// anyway.
15332fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15333    if Instant::now() < deadline {
15334        return false;
15335    }
15336    append_git_diagnostic(
15337        snapshot,
15338        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15339    );
15340    true
15341}
15342
15343/// Deadline-aware sibling of `run_git_bounded`, used only by
15344/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15345/// whatever remains of the shared `deadline` (never more than
15346/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15347/// of the budget cannot still hand a full per-call timeout to every git
15348/// probe behind it. `inspect_git_workspace` already guards each call site
15349/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15350/// passed yet here.
15351async fn run_git_bounded_with_deadline(
15352    root: &str,
15353    arguments: &[&str],
15354    output_limit: usize,
15355    deadline: Instant,
15356) -> io::Result<GitCommandOutput> {
15357    let remaining = deadline.checked_duration_since(Instant::now());
15358    let timeout_ms = remaining
15359        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15360        .unwrap_or(0)
15361        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15362    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15363}
15364
15365/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15366/// `deadline` is the same absolute instant the preceding directory walk was
15367/// bounded by, so time already spent walking counts against what remains
15368/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15369/// shared budget (not an individual per-call timeout) is what cut the git
15370/// phase short.
15371async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15372    let mut snapshot = GitSnapshot {
15373        is_repository: false,
15374        branch: None,
15375        status: Vec::new(),
15376        recent_commits: Vec::new(),
15377        worktrees: Vec::new(),
15378        managed_worktree: None,
15379        truncated: false,
15380        diagnostic: None,
15381    };
15382    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15383        return (snapshot, true);
15384    }
15385    let repository = match run_git_bounded_with_deadline(
15386        root,
15387        &["rev-parse", "--is-inside-work-tree"],
15388        4 * 1_024,
15389        deadline,
15390    )
15391    .await
15392    {
15393        Ok(output) => output,
15394        Err(error) => {
15395            append_git_diagnostic(
15396                &mut snapshot,
15397                &format!("git inspection unavailable: {error}"),
15398            );
15399            return (snapshot, false);
15400        }
15401    };
15402    snapshot.truncated |= repository.truncated;
15403    if repository.timed_out {
15404        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15405        return (snapshot, false);
15406    }
15407    if !repository.success
15408        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15409    {
15410        let stderr = String::from_utf8_lossy(&repository.stderr);
15411        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15412            append_git_diagnostic(&mut snapshot, stderr.trim());
15413        }
15414        return (snapshot, false);
15415    }
15416    snapshot.is_repository = true;
15417
15418    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15419        return (snapshot, true);
15420    }
15421    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15422        Ok(output) => {
15423            snapshot.truncated |= output.truncated;
15424            if output.timed_out {
15425                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15426            } else if output.success {
15427                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15428                if !branch.is_empty() {
15429                    snapshot.branch = Some(truncate_git_field(
15430                        &branch,
15431                        MAX_REPOSITORY_PATH_BYTES,
15432                        &mut snapshot.truncated,
15433                    ));
15434                }
15435            }
15436        }
15437        Err(error) => append_git_diagnostic(
15438            &mut snapshot,
15439            &format!("git branch query failed: {error}"),
15440        ),
15441    }
15442    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15443        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15444            snapshot.truncated |= output.truncated;
15445            if output.success && !output.timed_out {
15446                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15447                if !head.is_empty() {
15448                    snapshot.branch = Some(format!("detached:{head}"));
15449                }
15450            }
15451        }
15452    }
15453
15454    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15455        return (snapshot, true);
15456    }
15457    match run_git_bounded_with_deadline(
15458        root,
15459        &[
15460            "status",
15461            "--porcelain=v1",
15462            "-z",
15463            "--renames",
15464            "--untracked-files=normal",
15465            "--",
15466            ".",
15467        ],
15468        GIT_OUTPUT_MAX_BYTES,
15469        deadline,
15470    )
15471    .await
15472    {
15473        Ok(output) => {
15474            snapshot.truncated |= output.truncated;
15475            if output.timed_out {
15476                append_git_diagnostic(&mut snapshot, "git status timed out");
15477            } else if output.success {
15478                parse_git_status(&output.stdout, &mut snapshot);
15479            } else {
15480                append_git_diagnostic(
15481                    &mut snapshot,
15482                    String::from_utf8_lossy(&output.stderr).trim(),
15483                );
15484            }
15485        }
15486        Err(error) => append_git_diagnostic(
15487            &mut snapshot,
15488            &format!("git status failed: {error}"),
15489        ),
15490    }
15491
15492    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15493        return (snapshot, true);
15494    }
15495    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15496    match run_git_bounded_with_deadline(
15497        root,
15498        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15499        16 * 1_024,
15500        deadline,
15501    )
15502    .await
15503    {
15504        Ok(output) => {
15505            snapshot.truncated |= output.truncated;
15506            if output.timed_out {
15507                append_git_diagnostic(&mut snapshot, "git log timed out");
15508            } else if output.success {
15509                parse_git_commits(&output.stdout, &mut snapshot);
15510            } else {
15511                let stderr = String::from_utf8_lossy(&output.stderr);
15512                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15513                    append_git_diagnostic(&mut snapshot, stderr.trim());
15514                }
15515            }
15516        }
15517        Err(error) => append_git_diagnostic(
15518            &mut snapshot,
15519            &format!("git log failed: {error}"),
15520        ),
15521    }
15522
15523    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15524        return (snapshot, true);
15525    }
15526    match list_git_worktrees_with_deadline(root, deadline).await {
15527        Ok(worktrees) => {
15528            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15529        }
15530        Err(error) => append_git_diagnostic(
15531            &mut snapshot,
15532            &format!("git worktree list failed: {}", error.message),
15533        ),
15534    }
15535    (snapshot, false)
15536}
15537
15538fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15539    let mut remaining = output;
15540    while !remaining.is_empty() {
15541        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15542            snapshot.truncated = true;
15543            break;
15544        }
15545        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15546            snapshot.truncated = true;
15547            break;
15548        };
15549        let record = &remaining[..record_end];
15550        remaining = &remaining[record_end + 1..];
15551        let has_previous_path = record
15552            .get(..2)
15553            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15554        let previous_path_bytes = if has_previous_path {
15555            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15556                snapshot.truncated = true;
15557                break;
15558            };
15559            let previous = &remaining[..previous_end];
15560            remaining = &remaining[previous_end + 1..];
15561            Some(previous)
15562        } else {
15563            None
15564        };
15565        if record.len() < 4
15566            || record[2] != b' '
15567            || !is_git_status_code(record[0])
15568            || !is_git_status_code(record[1])
15569        {
15570            snapshot.truncated = true;
15571            continue;
15572        }
15573        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15574            snapshot.truncated = true;
15575            continue;
15576        };
15577        let previous_path = match previous_path_bytes {
15578            Some(previous) => {
15579                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15580                    snapshot.truncated = true;
15581                    continue;
15582                };
15583                Some(previous)
15584            }
15585            None => None,
15586        };
15587        snapshot.status.push(GitStatusEntry {
15588            index_status: (record[0] as char).to_string(),
15589            worktree_status: (record[1] as char).to_string(),
15590            path,
15591            previous_path,
15592        });
15593    }
15594}
15595
15596fn is_git_status_code(byte: u8) -> bool {
15597    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15598}
15599
15600fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15601    for line in String::from_utf8_lossy(output).lines() {
15602        let Some((id, summary)) = line.split_once('\u{1f}') else {
15603            continue;
15604        };
15605        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15606            snapshot.truncated = true;
15607            break;
15608        }
15609        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15610        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15611        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15612    }
15613}
15614
15615fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15616    if value.len() <= max_bytes {
15617        return value.to_owned();
15618    }
15619    *truncated = true;
15620    let mut boundary = max_bytes;
15621    while boundary > 0 && !value.is_char_boundary(boundary) {
15622        boundary -= 1;
15623    }
15624    value[..boundary].to_owned()
15625}
15626
15627fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15628    if message.is_empty() {
15629        return;
15630    }
15631    let normalized = message
15632        .chars()
15633        .map(|character| if character.is_control() { ' ' } else { character })
15634        .collect::<String>();
15635    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15636    if !diagnostic.is_empty() {
15637        diagnostic.push_str("; ");
15638    }
15639    diagnostic.push_str(normalized.trim());
15640    snapshot.diagnostic = Some(truncate_git_field(
15641        &diagnostic,
15642        GIT_DIAGNOSTIC_MAX_BYTES,
15643        &mut snapshot.truncated,
15644    ));
15645}
15646
15647async fn run_git_bounded(
15648    root: &str,
15649    arguments: &[&str],
15650    output_limit: usize,
15651) -> io::Result<GitCommandOutput> {
15652    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15653}
15654
15655async fn read_git_history_bounded(
15656    root: &str,
15657    path: Option<&RepositoryPath>,
15658    before: Option<&GitObjectId>,
15659    limit: u16,
15660) -> Result<GitHistoryPage, NodeFailure> {
15661    let path = path
15662        .map(|path| {
15663            path.as_utf8().ok_or_else(|| {
15664                failure(
15665                    NodeFailureCode::InvalidRepositoryPath,
15666                    "git history path must use UTF-8",
15667                )
15668            })
15669        })
15670        .transpose()?
15671        .unwrap_or(".");
15672    let count = (usize::from(limit) + 1).to_string();
15673    let start = before
15674        .map(|revision| format!("{}^", revision.as_str()))
15675        .unwrap_or_else(|| "HEAD".to_owned());
15676    let arguments = [
15677        "--no-pager",
15678        "log",
15679        "--no-decorate",
15680        "--date=iso-strict",
15681        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15682        "-n",
15683        count.as_str(),
15684        start.as_str(),
15685        "--",
15686        path,
15687    ];
15688    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15689        .await
15690        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15691    if output.timed_out {
15692        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15693    }
15694    if !output.success {
15695        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15696    }
15697    let mut commits = parse_git_history(&output.stdout);
15698    let has_more = commits.len() > usize::from(limit) || output.truncated;
15699    commits.truncate(usize::from(limit));
15700    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15701    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15702}
15703
15704fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15705    String::from_utf8_lossy(output)
15706        .split('\u{1e}')
15707        .filter_map(|record| {
15708            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15709            if fields.len() != 9 {
15710                return None;
15711            }
15712            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15713            let parents = if fields[1].is_empty() {
15714                Vec::new()
15715            } else {
15716                fields[1]
15717                    .split(' ')
15718                    .map(|value| GitObjectId::new(value.to_owned()))
15719                    .collect::<Result<Vec<_>, _>>()
15720                    .ok()?
15721            };
15722            Some(GitCommitDetails {
15723                id,
15724                parents,
15725                subject: bounded_git_text(fields[2], 512),
15726                author_name: bounded_git_text(fields[3], 256),
15727                author_email: bounded_git_text(fields[4], 320),
15728                authored_at: bounded_git_text(fields[5], 64),
15729                committer_name: bounded_git_text(fields[6], 256),
15730                committer_email: bounded_git_text(fields[7], 320),
15731                committed_at: bounded_git_text(fields[8], 64),
15732                signature_status: GitSignatureStatus::NoSignature,
15733                signer: None,
15734            })
15735        })
15736        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15737        .collect()
15738}
15739
15740fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15741    let normalized = value
15742        .chars()
15743        .map(|character| if character.is_control() { ' ' } else { character })
15744        .collect::<String>();
15745    if normalized.len() <= max_bytes {
15746        return normalized;
15747    }
15748    let mut boundary = max_bytes;
15749    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15750        boundary -= 1;
15751    }
15752    normalized[..boundary].to_owned()
15753}
15754
15755async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15756    let path = request.path.as_ref().map(|path| {
15757        path.as_utf8()
15758            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15759    }).transpose()?;
15760    let mut arguments = match &request.mode {
15761        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15762        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15763        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15764    };
15765    if let GitDiffMode::Commit { revision } = &request.mode {
15766        arguments.push(revision.as_str());
15767    }
15768    arguments.push("--");
15769    if let Some(path) = path {
15770        arguments.push(path);
15771    }
15772    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15773        .await
15774        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15775    if output.timed_out {
15776        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15777    }
15778    if !output.success {
15779        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15780    }
15781    Ok(GitDiff {
15782        mode: request.mode,
15783        path: request.path,
15784        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15785        truncated: output.truncated,
15786    })
15787}
15788
15789async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15790    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15791    ResponseEnvelope { request_id: envelope.request_id, result }
15792}
15793
15794async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15795    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15796        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15797    }
15798    let read_only = matches!(
15799        &request,
15800        NodeRequest::Snapshot
15801            | NodeRequest::Resync { .. }
15802            | NodeRequest::BrowseHostDirectories { .. }
15803            | NodeRequest::InspectWorkspace { .. }
15804            | NodeRequest::ReadWorkspaceFile { .. }
15805            | NodeRequest::ReadGitHistory { .. }
15806            | NodeRequest::ReadGitDiff { .. }
15807            | NodeRequest::CatalogNativeSessions { .. }
15808            | NodeRequest::PageNativeSessions { .. }
15809            | NodeRequest::PreviewNativeSession { .. }
15810            | NodeRequest::PreviewSessionRecord { .. }
15811    );
15812    let _mutation_guard = if read_only {
15813        None
15814    } else {
15815        let guard = shared.mutation_gate.lock().await;
15816        if shared.shutdown.load(Ordering::Acquire) {
15817            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15818        }
15819        Some(guard)
15820    };
15821    match request {
15822        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15823            event_sequence: shared.current_sequence(),
15824            controller: shared.controller_state(),
15825            snapshot: shared.snapshot(),
15826        }),
15827        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15828        NodeRequest::ArmHarnessMcpReservation {
15829            reservation_id,
15830            activation_digest,
15831            spawn_spec,
15832            launch,
15833            expires_at_unix_ms,
15834        } => {
15835            shared.require_controller(connection_id, role)?;
15836            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15837                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15838            })?;
15839            let expires_at_unix_ms = registry
15840                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15841                .await
15842                .map_err(harness_mcp_failure)?;
15843            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15844        }
15845        NodeRequest::SpawnSpecWithHarnessMcp {
15846            reservation_id,
15847            activation_digest,
15848            spec,
15849            deadline_unix_ms,
15850        } => {
15851            shared.require_controller(connection_id, role)?;
15852            let receipt = shared
15853                .spawn_from_spec_with_harness_mcp(
15854                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15855                )
15856                .await?;
15857            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15858        }
15859        NodeRequest::ActivateHarnessMcpReservation {
15860            reservation_id,
15861            activation_digest,
15862            record_id,
15863            session,
15864        } => {
15865            shared.require_controller(connection_id, role)?;
15866            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15867                current.instance_id == session.session.instance_id
15868                    && current.generation == session.session.generation
15869            }).and_then(|current| current.process_id).ok_or_else(|| {
15870                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15871            })?;
15872            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15873                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15874            })?.activate(
15875                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15876            ).map_err(harness_mcp_failure)?;
15877            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15878        }
15879        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15880            shared.require_controller(connection_id, role)?;
15881            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15882                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15883            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15884            if let Some(instance_id) = instance_id {
15885                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15886            }
15887            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15888        }
15889        NodeRequest::PutHarnessMcpReplyChunk {
15890            reservation_id, activation_digest, record_id, session, call_id,
15891            offset, final_chunk, chunk_hex,
15892        } => {
15893            shared.require_controller(connection_id, role)?;
15894            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15895                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15896            })?.put_reply_chunk(
15897                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15898                offset, final_chunk, &chunk_hex,
15899            ).map_err(harness_mcp_failure)?;
15900            Ok(NodeResponse::ReplyChunkAccepted {
15901                reservation_id, activation_digest, record_id, session, call_id,
15902                next_offset, completed,
15903            })
15904        }
15905        NodeRequest::RejectHarnessMcpCall {
15906            reservation_id, activation_digest, record_id, session, call_id, reason,
15907        } => {
15908            shared.require_controller(connection_id, role)?;
15909            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15910                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15911            })?.reject_call(
15912                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15913            ).map_err(harness_mcp_failure)?;
15914            Ok(NodeResponse::CallRejected {
15915                reservation_id, activation_digest, record_id, session, call_id,
15916            })
15917        }
15918        NodeRequest::BeginDeliveryStage { manifest } => {
15919            shared.require_controller(connection_id, role)?;
15920            shared.begin_delivery_stage(manifest)
15921        }
15922        NodeRequest::PutDeliveryBlobChunk {
15923            stage_id,
15924            blob_digest,
15925            offset,
15926            chunk_hex,
15927        } => {
15928            shared.require_controller(connection_id, role)?;
15929            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15930        }
15931        NodeRequest::CommitDeliveryStage { stage_id } => {
15932            shared.require_controller(connection_id, role)?;
15933            let receipt = shared.commit_delivery_stage(stage_id)?;
15934            Ok(NodeResponse::DeliveryCommitted { receipt })
15935        }
15936        NodeRequest::AbortDeliveryStage { stage_id } => {
15937            shared.require_controller(connection_id, role)?;
15938            shared.abort_delivery_stage(&stage_id)?;
15939            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15940        }
15941        NodeRequest::BrowseHostDirectories { directory, after } => {
15942            let listing = shared.browse_host_directories(directory, after).await?;
15943            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15944        }
15945        NodeRequest::InspectWorkspace { workspace_id } => {
15946            let inspection = shared.inspect_workspace(workspace_id).await?;
15947            Ok(NodeResponse::WorkspaceInspected { inspection })
15948        }
15949        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15950            let file = shared.read_workspace_file(workspace_id, path).await?;
15951            Ok(NodeResponse::WorkspaceFileRead { file })
15952        }
15953        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15954            shared.require_controller(connection_id, role)?;
15955            let file = shared
15956                .write_workspace_file(workspace_id, path, expected_revision, text)
15957                .await?;
15958            Ok(NodeResponse::WorkspaceFileWritten { file })
15959        }
15960        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15961            shared.require_controller(connection_id, role)?;
15962            let file = shared.create_workspace_file(workspace_id, path).await?;
15963            Ok(NodeResponse::WorkspaceFileCreated { file })
15964        }
15965        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15966            shared.require_controller(connection_id, role)?;
15967            let entry = shared
15968                .create_workspace_directory(workspace_id.clone(), path)
15969                .await?;
15970            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15971        }
15972        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15973            if role != ClientRole::Operator {
15974                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15975            }
15976            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15977                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15978            }
15979            let page = shared
15980                .read_git_history(workspace_id.clone(), path, before, limit)
15981                .await?;
15982            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15983        }
15984        NodeRequest::ReadGitDiff { workspace_id, request } => {
15985            if role != ClientRole::Operator {
15986                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15987            }
15988            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15989            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15990        }
15991        NodeRequest::CatalogNativeSessions { route, limit } => {
15992            if role != ClientRole::Operator {
15993                return Err(failure(
15994                    NodeFailureCode::ObserverReadOnly,
15995                    "native session catalog requires an operator connection",
15996                ));
15997            }
15998            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15999                return Err(failure(
16000                    NodeFailureCode::InvalidRequest,
16001                    "native session catalog limit is invalid",
16002                ));
16003            }
16004            let (entries, summary) = shared
16005                .catalog_native_sessions(route.clone(), limit)
16006                .await?;
16007            Ok(NodeResponse::NativeSessionsCataloged {
16008                route,
16009                entries,
16010                summary: Some(summary),
16011            })
16012        }
16013        NodeRequest::PageNativeSessions {
16014            route,
16015            window,
16016            catalog_revision,
16017            recent_cutoff_unix_ms,
16018            after_selection_id,
16019            limit,
16020        } => {
16021            if role != ClientRole::Operator {
16022                return Err(failure(
16023                    NodeFailureCode::ObserverReadOnly,
16024                    "native session catalog paging requires an operator connection",
16025                ));
16026            }
16027            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16028                || after_selection_id
16029                    .as_deref()
16030                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16031            {
16032                return Err(failure(
16033                    NodeFailureCode::InvalidRequest,
16034                    "native session catalog page request is invalid",
16035                ));
16036            }
16037            let page = shared
16038                .page_native_sessions(
16039                    route.clone(),
16040                    window,
16041                    catalog_revision,
16042                    recent_cutoff_unix_ms,
16043                    after_selection_id,
16044                    limit,
16045                )
16046                .await?;
16047            Ok(NodeResponse::NativeSessionsPaged {
16048                route,
16049                page,
16050            })
16051        }
16052        NodeRequest::PreviewNativeSession {
16053            selection,
16054            message_limit,
16055        } => {
16056            if role != ClientRole::Operator {
16057                return Err(failure(
16058                    NodeFailureCode::ObserverReadOnly,
16059                    "native session preview requires an operator connection",
16060                ));
16061            }
16062            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16063                .contains(&message_limit)
16064                || selection.validate().is_err()
16065            {
16066                return Err(failure(
16067                    NodeFailureCode::InvalidRequest,
16068                    "native session preview request is invalid",
16069                ));
16070            }
16071            let preview = shared
16072                .preview_native_session(selection.clone(), message_limit)
16073                .await?;
16074            Ok(NodeResponse::NativeSessionPreviewed {
16075                selection,
16076                preview,
16077            })
16078        }
16079        NodeRequest::IndexNativeSession {
16080            selection,
16081            display_name,
16082        } => {
16083            shared.require_controller(connection_id, role)?;
16084            let record = shared
16085                .index_native_session(selection.clone(), display_name)
16086                .await?;
16087            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16088        }
16089        NodeRequest::PreviewSessionRecord {
16090            record_id,
16091            message_limit,
16092        } => {
16093            if role != ClientRole::Operator {
16094                return Err(failure(
16095                    NodeFailureCode::ObserverReadOnly,
16096                    "native session preview requires an operator connection",
16097                ));
16098            }
16099            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16100                .contains(&message_limit)
16101            {
16102                return Err(failure(
16103                    NodeFailureCode::InvalidRequest,
16104                    "native session preview request is invalid",
16105                ));
16106            }
16107            let record = shared.record(&record_id)?;
16108            let working_directory = shared.workspace_root(&record.workspace_id)?;
16109            if !platform::roots_equal(
16110                &working_directory,
16111                windows_path_text(&record.canonical_root),
16112            ) {
16113                return Err(failure(
16114                    NodeFailureCode::SessionRecordConflict,
16115                    "managed session workspace root changed; refusing to preview another directory",
16116                ));
16117            }
16118            let identity = record.provider_session.clone().ok_or_else(|| {
16119                failure(
16120                    NodeFailureCode::BackendOperationFailed,
16121                    "native session preview is unavailable for this session record",
16122                )
16123            })?;
16124            if identity.key != ProviderSessionKey::SessionId {
16125                return Err(failure(
16126                    NodeFailureCode::BackendOperationFailed,
16127                    "native session preview is unavailable for this session identity",
16128                ));
16129            }
16130            let preview = shared
16131                .preview_native_session_by_session_id(
16132                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16133                )
16134                .await?;
16135            if preview.session_id != identity.id {
16136                return Err(failure(
16137                    NodeFailureCode::SessionRecordConflict,
16138                    "native session preview identity changed while loading",
16139                ));
16140            }
16141            shared.revalidate_session_record_preview(&record, &identity)?;
16142            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16143            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16144                record_id: record_id.clone(),
16145                summary: SessionHistorySummaryV1::from(&preview),
16146            });
16147            Ok(NodeResponse::SessionRecordPreviewed {
16148                record_id,
16149                preview,
16150            })
16151        }
16152        NodeRequest::AcquireController { lease_ms } => {
16153            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16154            Ok(NodeResponse::Controller { controller: Some(controller) })
16155        }
16156        NodeRequest::ReleaseController => {
16157            shared.release_controller(connection_id);
16158            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16159        }
16160        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16161            shared.require_controller(connection_id, role)?;
16162            let root = require_windows_path(root)?;
16163            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16164            let workspace = shared.register_workspace(workspace_id, root).await?;
16165            Ok(NodeResponse::WorkspaceRegistered { workspace })
16166        }
16167        NodeRequest::CreateStandaloneWorkspace {
16168            workspace_id,
16169            root,
16170            initial_branch,
16171        } => {
16172            shared.require_controller(connection_id, role)?;
16173            let root = require_windows_path(root)?;
16174            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16175            let workspace = shared
16176                .create_standalone_workspace(workspace_id, root, initial_branch)
16177                .await?;
16178            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16179        }
16180        NodeRequest::UnregisterWorkspace { workspace_id } => {
16181            shared.require_controller(connection_id, role)?;
16182            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16183            shared.unregister_workspace(&workspace_id)?;
16184            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16185        }
16186        NodeRequest::CreateWorktree {
16187            source_workspace_id,
16188            workspace_id,
16189            target_root,
16190            branch,
16191            base,
16192        } => {
16193            shared.require_controller(connection_id, role)?;
16194            let target_root = require_windows_path(target_root)?;
16195            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16196            let (worktree, workspace) = shared
16197                .create_worktree(
16198                    source_workspace_id,
16199                    workspace_id,
16200                    target_root,
16201                    branch,
16202                    base,
16203                )
16204                .await?;
16205            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16206        }
16207        NodeRequest::RemoveWorktree {
16208            source_workspace_id,
16209            target_root,
16210        } => {
16211            shared.require_controller(connection_id, role)?;
16212            let native_target_root = require_windows_path(target_root.clone())?;
16213            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16214            let workspace_id = shared
16215                .remove_worktree(source_workspace_id, native_target_root)
16216                .await?;
16217            Ok(NodeResponse::WorktreeRemoved {
16218                target_root,
16219                workspace_id,
16220            })
16221        }
16222        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16223            shared.require_controller(connection_id, role)?;
16224            if !terminal_size.is_valid() {
16225                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16226            }
16227            if let Some(prompt) = initial_prompt.as_deref() {
16228                validate_node_text("spawn initial prompt", prompt)?;
16229            }
16230            match shared
16231                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16232                .await
16233            {
16234                Ok(session) => {
16235                    shared.mark_spawn_provisional(session.clone());
16236                    Ok(NodeResponse::SpawnAccepted { session })
16237                }
16238                Err(err) => {
16239                    tracing::warn!(
16240                        %provider,
16241                        %workspace_id,
16242                        ?mode,
16243                        code = ?err.code,
16244                        message = %err.message,
16245                        "session spawn refused"
16246                    );
16247                    Err(err)
16248                }
16249            }
16250        }
16251        NodeRequest::SpawnSpec { spec } => {
16252            shared.require_controller(connection_id, role)?;
16253            let receipt = shared.spawn_from_spec(spec).await?;
16254            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16255        }
16256        NodeRequest::SpawnManagedWorktree { request } => {
16257            shared.require_controller(connection_id, role)?;
16258            let receipt = shared.spawn_managed_worktree(request).await?;
16259            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16260        }
16261        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16262            shared.require_controller(connection_id, role)?;
16263            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16264            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16265        }
16266        NodeRequest::CleanupManagedWorktree { lease_id } => {
16267            shared.require_controller(connection_id, role)?;
16268            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16269            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16270        }
16271        NodeRequest::DiscoverHistory { session, limit } => {
16272            controlled_session(shared, connection_id, role, &session)?;
16273            let candidates = shared.discover_history(&session, limit).await?;
16274            Ok(NodeResponse::HistoryDiscovered {
16275                session,
16276                candidates,
16277            })
16278        }
16279        NodeRequest::LoadHistory {
16280            session,
16281            candidate_id,
16282        } => {
16283            controlled_session(shared, connection_id, role, &session)?;
16284            let loaded = shared
16285                .load_history(&session, candidate_id)
16286                .await?;
16287            Ok(NodeResponse::HistoryLoaded {
16288                session,
16289                session_id: loaded.session_id,
16290                message_count: loaded.message_count,
16291                completed_turn_count: loaded.completed_turn_count,
16292            })
16293        }
16294        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16295            shared.require_controller(connection_id, role)?;
16296            let context = shared
16297                .export_context_pack_for_session_record(&record_id, &session, false)
16298                .await?;
16299            Ok(NodeResponse::ContextPackForSessionRecordExported {
16300                record_id,
16301                session,
16302                context,
16303            })
16304        }
16305        NodeRequest::ExportContextPack { session } => {
16306            controlled_session(shared, connection_id, role, &session)?;
16307            let context = shared.export_context_pack(&session).await?;
16308            Ok(NodeResponse::ContextPackExported { context })
16309        }
16310        NodeRequest::ForgetContextPack { context_id } => {
16311            shared.require_controller(connection_id, role)?;
16312            shared.forget_context_pack(&context_id)?;
16313            Ok(NodeResponse::ContextPackForgotten { context_id })
16314        }
16315        NodeRequest::ResolveDurableContextPack { context_id } => {
16316            // Read-only, idempotent, no controller lease required: this lets
16317            // a caller preflight "is this durably exported pack still here"
16318            // without holding the Node's mutation controller.
16319            let context = shared
16320                .context_catalog
16321                .read()
16322                .unwrap_or_else(|poisoned| poisoned.into_inner())
16323                .get(&context_id)
16324                .map(|pack| pack.receipt().clone())
16325                .ok_or_else(|| {
16326                    failure(
16327                        NodeFailureCode::UnknownContextPack,
16328                        "context pack is not present",
16329                    )
16330                })?;
16331            Ok(NodeResponse::DurableContextPackResolved { context })
16332        }
16333        NodeRequest::ReadContextPack { digest } => {
16334            // Read-only fetch of the pack's own bytes, keyed by content digest
16335            // rather than the caller's `SpawnContextId`: same no-controller-lease
16336            // rationale as `ResolveDurableContextPack` above, one step further
16337            // down the same catalog.
16338            let (id, byte_len, bytes) = shared
16339                .context_catalog
16340                .read()
16341                .unwrap_or_else(|poisoned| poisoned.into_inner())
16342                .get_by_digest(&digest)
16343                .map(|pack| {
16344                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16345                })
16346                .ok_or_else(|| {
16347                    failure(
16348                        NodeFailureCode::UnknownContextPack,
16349                        &format!("context pack {digest} not on this node"),
16350                    )
16351                })?;
16352            tracing::debug!(
16353                digest = digest.as_str(),
16354                byte_len,
16355                "read context pack bytes",
16356            );
16357            Ok(NodeResponse::ContextPackBytesRead {
16358                pack: ContextPackBytesRead {
16359                    digest,
16360                    id,
16361                    byte_len,
16362                    bytes,
16363                },
16364            })
16365        }
16366        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16367            let provider = controlled_session(shared, connection_id, role, &session)?;
16368            if let Some(prompt) = initial_prompt.as_deref() {
16369                validate_node_text("resume initial prompt", prompt)?;
16370            }
16371            let runtime_requirement = if initial_prompt.is_some() {
16372                ProviderRuntimeRequirement::ResumeWithPrompt
16373            } else {
16374                ProviderRuntimeRequirement::Resume
16375            };
16376            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16377            let runtime_policy = shared
16378                .admit_provider_runtime(&provider, runtime_requirement)
16379                .await?;
16380            let working_directory = shared.bound_workspace_root(&session)?;
16381            let request = ResumeLaunchRequest {
16382                working_directory,
16383                terminal_size,
16384                initial_prompt,
16385            };
16386            request
16387                .validate()
16388                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16389            let command = shared.prepare_command(ControlCommand::Resume {
16390                instance_id: session.session.instance_id,
16391                target: ResumeTarget::CurrentProvider,
16392                runtime_policy,
16393                request,
16394            });
16395            shared.arm_resume(&session, command.id, runtime_policy)?;
16396            let dispatch = shared.dispatch_envelope(command);
16397            if let Err(error) = dispatch {
16398                shared.clear_armed_resume(&session);
16399                return Err(error);
16400            }
16401            Ok(NodeResponse::Accepted)
16402        }
16403        NodeRequest::RenameSessionRecord {
16404            record_id,
16405            display_name,
16406        } => {
16407            shared.require_controller(connection_id, role)?;
16408            let record = shared.rename_session_record(&record_id, display_name)?;
16409            Ok(NodeResponse::SessionRecordUpdated { record })
16410        }
16411        NodeRequest::SetSessionTask {
16412            record_id,
16413            expected_revision,
16414            target,
16415        } => {
16416            shared.require_controller(connection_id, role)?;
16417            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16418            Ok(NodeResponse::SessionRecordUpdated { record })
16419        }
16420        NodeRequest::IndexProviderSession {
16421            workspace_id,
16422            provider,
16423            identity,
16424            display_name,
16425        } => {
16426            shared.require_controller(connection_id, role)?;
16427            let record = shared.index_provider_session(
16428                workspace_id,
16429                provider,
16430                identity,
16431                display_name,
16432            )?;
16433            Ok(NodeResponse::ProviderSessionIndexed { record })
16434        }
16435        NodeRequest::ResumeSessionRecord {
16436            record_id,
16437            terminal_size,
16438            initial_prompt,
16439        } => {
16440            shared.require_controller(connection_id, role)?;
16441            if let Some(prompt) = initial_prompt.as_deref() {
16442                validate_node_text("managed resume initial prompt", prompt)?;
16443            }
16444            let (record, session) = shared
16445                .resume_session_record(
16446                    &record_id,
16447                    terminal_size,
16448                    initial_prompt,
16449                )
16450                .await?;
16451            Ok(NodeResponse::SessionRecordResumed { record, session })
16452        }
16453        NodeRequest::ForgetSessionRecord { record_id } => {
16454            shared.require_controller(connection_id, role)?;
16455            shared.forget_session_record(&record_id).await?;
16456            Ok(NodeResponse::SessionRecordForgotten { record_id })
16457        }
16458        NodeRequest::Prompt { session, text } => {
16459            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16460            validate_node_text("prompt", &text)?;
16461            shared.require_session_runtime_policy(
16462                &session,
16463                ProviderRuntimeRequirement::SemanticPrompt,
16464            )?;
16465            let framing = prompt_framing(&agent_id);
16466            shared
16467                .dispatch_input_bounded(
16468                    &session,
16469                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16470                )
16471                .await?;
16472            Ok(NodeResponse::Accepted)
16473        }
16474        NodeRequest::Paste { session, text } => {
16475            controlled_session(shared, connection_id, role, &session)?;
16476            validate_node_text("paste", &text)?;
16477            shared.require_session_runtime_policy(
16478                &session,
16479                ProviderRuntimeRequirement::SemanticPrompt,
16480            )?;
16481            shared
16482                .dispatch_input_bounded(
16483                    &session,
16484                    InputAction::InsertDraft(PromptPayload {
16485                        text,
16486                        framing: PromptFraming::BracketedPaste,
16487                    }),
16488                )
16489                .await?;
16490            Ok(NodeResponse::Accepted)
16491        }
16492        NodeRequest::Input { session, text } => {
16493            controlled_session(shared, connection_id, role, &session)?;
16494            validate_node_text("terminal input", &text)?;
16495            shared
16496                .dispatch_input_bounded(
16497                    &session,
16498                    InputAction::TerminalText(TerminalText { text }),
16499                )
16500                .await?;
16501            Ok(NodeResponse::Accepted)
16502        }
16503        NodeRequest::TerminalBytes { session, bytes } => {
16504            controlled_session(shared, connection_id, role, &session)?;
16505            shared.sessionize_spawn(session.session.instance_id);
16506            let action = terminal_bytes_action(bytes)?;
16507            shared
16508                .dispatch_input_bounded(&session, action)
16509                .await?;
16510            Ok(NodeResponse::Accepted)
16511        }
16512        NodeRequest::TerminalControl { session, control } => {
16513            controlled_session(shared, connection_id, role, &session)?;
16514            shared
16515                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16516                .await?;
16517            Ok(NodeResponse::Accepted)
16518        }
16519        NodeRequest::Resize { session, size } => {
16520            controlled_session(shared, connection_id, role, &session)?;
16521            shared.sessionize_spawn(session.session.instance_id);
16522            if !size.is_valid() {
16523                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16524            }
16525            shared.dispatch_resize_bounded(&session, size).await?;
16526            Ok(NodeResponse::Accepted)
16527        }
16528        NodeRequest::Interrupt { session } => {
16529            controlled_session(shared, connection_id, role, &session)?;
16530            shared
16531                .dispatch_input_bounded(
16532                    &session,
16533                    InputAction::TerminalControl(TerminalControl::Interrupt),
16534                )
16535                .await?;
16536            Ok(NodeResponse::Accepted)
16537        }
16538        NodeRequest::Stop { session, force } => {
16539            controlled_session(shared, connection_id, role, &session)?;
16540            shared.sessionize_spawn(session.session.instance_id);
16541            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16542            Ok(NodeResponse::Accepted)
16543        }
16544        NodeRequest::Remove { session } => {
16545            controlled_session(shared, connection_id, role, &session)?;
16546            shared.remove_session(&session).await?;
16547            Ok(NodeResponse::Accepted)
16548        }
16549        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16550            shared.require_controller(connection_id, role)?;
16551            let snapshot = shared.require_acp_session(&session)?;
16552            let interaction = snapshot
16553                .provider
16554                .interactions
16555                .iter()
16556                .find(|interaction| {
16557                    interaction_correlation(
16558                        session.session.instance_id,
16559                        session.session.generation,
16560                        interaction.id.0,
16561                    ) == correlation_id
16562                })
16563                .ok_or_else(|| failure(
16564                    NodeFailureCode::InvalidRequest,
16565                    "unknown interaction correlation",
16566                ))?;
16567            if interaction.status != ProviderInteractionStatus::Pending {
16568                return Err(failure(
16569                    NodeFailureCode::BackendOperationFailed,
16570                    "interaction has already been resolved",
16571                ));
16572            }
16573            response
16574                .validate_for(interaction.interaction_kind)
16575                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16576            let interaction_id = interaction.id;
16577            let response_kind = response.kind();
16578            let response_outcome = response.outcome();
16579            shared.dispatch(ControlCommand::ResolveInteraction {
16580                instance_id: session.session.instance_id,
16581                generation: session.session.generation,
16582                interaction_id,
16583                response,
16584            })?;
16585            // The one line that lets an operator compare what THIS node
16586            // recorded against what the provider itself reports doing --
16587            // measured live on a kimi run where our side resolved `true`
16588            // while the agent reported "rejected" and neither layer had a
16589            // line to line up against the other.
16590            tracing::info!(
16591                node_id = %shared.node_id,
16592                session = ?session.session,
16593                correlation_id = %correlation_id,
16594                interaction_id = interaction_id.0,
16595                response_kind = ?response_kind,
16596                outcome = ?response_outcome,
16597                "interaction resolved",
16598            );
16599            Ok(NodeResponse::Accepted)
16600        }
16601        NodeRequest::SetSessionMode { session, mode_id } => {
16602            shared.require_controller(connection_id, role)?;
16603            shared.require_acp_session(&session)?;
16604            shared.dispatch(ControlCommand::SetSessionMode {
16605                instance_id: session.session.instance_id,
16606                mode_id,
16607            })?;
16608            Ok(NodeResponse::Accepted)
16609        }
16610        NodeRequest::SetSessionConfigOption {
16611            session,
16612            option_id,
16613            value_json,
16614        } => {
16615            shared.require_controller(connection_id, role)?;
16616            shared.require_acp_session(&session)?;
16617            shared.dispatch(ControlCommand::SetSessionConfigOption {
16618                instance_id: session.session.instance_id,
16619                option_id,
16620                value_json,
16621            })?;
16622            Ok(NodeResponse::Accepted)
16623        }
16624        NodeRequest::SetSessionModel { session, model_id } => {
16625            shared.require_controller(connection_id, role)?;
16626            shared.require_acp_session(&session)?;
16627            shared.dispatch(ControlCommand::SetSessionModel {
16628                instance_id: session.session.instance_id,
16629                model_id,
16630            })?;
16631            Ok(NodeResponse::Accepted)
16632        }
16633        NodeRequest::Shutdown => {
16634            shared.require_controller(connection_id, role)?;
16635            shared.begin_shutdown_locked().await?;
16636            Ok(NodeResponse::ShuttingDown)
16637        }
16638    }
16639}
16640
16641fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16642    shared.require_controller(connection_id, role)?;
16643    shared.validate_address(session)
16644}
16645
16646/// Selects the sole history candidate matching `provider_session_id` when
16647/// the managed record carries a verified identity, or the sole candidate in
16648/// the discovered set at all when it does not (see
16649/// `export_context_pack_for_session_record_inner`'s identity match). Either
16650/// way, more than one match refuses instead of guessing which one is exact.
16651fn select_history_candidate(
16652    candidates: &[HistoryCandidateSummary],
16653    provider_session_id: Option<&str>,
16654) -> Result<String, NodeFailure> {
16655    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16656        Some(id) => candidate.session_id_hint == id,
16657        None => true,
16658    });
16659    let candidate = matches.next().ok_or_else(|| {
16660        failure(
16661            NodeFailureCode::SessionRecordConflict,
16662            if provider_session_id.is_some() {
16663                "no history candidate matches the managed provider session"
16664            } else {
16665                "no history candidate exists for the managed session"
16666            },
16667        )
16668    })?;
16669    if matches.next().is_some() {
16670        return Err(failure(
16671            NodeFailureCode::SessionRecordConflict,
16672            if provider_session_id.is_some() {
16673                "history identity is ambiguous for the managed provider session"
16674            } else {
16675                "history is ambiguous without a verified provider session identity"
16676            },
16677        ));
16678    }
16679    Ok(candidate.id.clone())
16680}
16681
16682fn resolved_spawn_profile_summary(
16683    profile: &SpawnProfileDefaults,
16684    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16685) -> Option<SpawnProfileSummary> {
16686    let environment_profile = match profile.environment_profile_id.as_ref() {
16687        None => None,
16688        Some(profile_id) => {
16689            let binding = environment_profiles.get(profile_id)?;
16690            if binding.provider != profile.provider
16691                || binding.native_profile_id(profile.mode).is_none()
16692            {
16693                return None;
16694            }
16695            Some(ResolvedEnvironmentProfileReceipt {
16696                profile_id: binding.id.clone(),
16697                profile_revision: binding.revision.clone(),
16698                network_allowlist: None,
16699                browser_profile_id: None,
16700            })
16701        }
16702    };
16703    Some(SpawnProfileSummary {
16704        id: profile.profile_id.clone(),
16705        revision: profile.revision.clone(),
16706        environment_profile,
16707    })
16708}
16709
16710/// True if `current` is an acceptable version of `expected` at export
16711/// revalidation/commit time.
16712///
16713/// When `allow_clean_detachment` is `false` (the on-demand,
16714/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16715/// be byte-identical to `expected` and still actively bound to `session` —
16716/// the original, unchanged invariant.
16717///
16718/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16719/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16720/// export reacts to produces on its own: `state` Live -> Dormant,
16721/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16722/// advancing. Every other field — including `provider_session`, `context`,
16723/// `bundle`, `task_binding` — must still match exactly. A record that changed
16724/// in any other way (rebind, a different active session, an identity
16725/// conflict, capacity eviction) is rejected either way; this widens nothing
16726/// about which sessions are eligible, only how the record's own expected
16727/// detachment is tolerated mid-export.
16728fn session_record_export_target_matches(
16729    expected: &ManagedSessionRecord,
16730    current: &ManagedSessionRecord,
16731    session: &SessionAddress,
16732    allow_clean_detachment: bool,
16733) -> bool {
16734    if current == expected && current.active_session.as_ref() == Some(session) {
16735        return current.state == ManagedSessionState::Live;
16736    }
16737    allow_clean_detachment
16738        && current.state == ManagedSessionState::Dormant
16739        && current.active_session.is_none()
16740        && current.record_id == expected.record_id
16741        && current.display_name == expected.display_name
16742        && current.provider == expected.provider
16743        && current.mode == expected.mode
16744        && current.workspace_id == expected.workspace_id
16745        && current.canonical_root == expected.canonical_root
16746        && current.provider_session == expected.provider_session
16747        && current.environment_profile == expected.environment_profile
16748        && current.bundle == expected.bundle
16749        && current.context_id == expected.context_id
16750        && current.context == expected.context
16751        && current.exported_context == expected.exported_context
16752        && current.task_binding == expected.task_binding
16753        && current.created_at_unix_ms == expected.created_at_unix_ms
16754        && current.last_error == expected.last_error
16755}
16756
16757fn session_record_context_export_binding_is_exact(
16758    expected: &ManagedSessionRecord,
16759    current: &ManagedSessionRecord,
16760    identity: Option<&ProviderSessionIdentity>,
16761    session: &SessionAddress,
16762    binding: &SessionBinding,
16763    provider: &AgentId,
16764    current_root: &str,
16765    allow_clean_detachment: bool,
16766) -> bool {
16767    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16768        && current.provider_session.as_ref() == identity
16769        && current.workspace_id == session.workspace_id
16770        && provider == &current.provider
16771        && binding.workspace_id == session.workspace_id
16772        && binding.generation == session.session.generation
16773        && binding.record_id.as_ref() == Some(&current.record_id)
16774        && platform::roots_equal(
16775            current_root,
16776            windows_path_text(&current.canonical_root),
16777        )
16778}
16779
16780fn session_record_context_export_source_is_usable(
16781    record: &ManagedSessionRecord,
16782    status: &SessionStatus,
16783    allow_clean_detachment: bool,
16784) -> bool {
16785    (record.state == ManagedSessionState::Live
16786        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16787        && context_pack_source_status_is_usable(status)
16788}
16789
16790fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16791    PromptFraming::BracketedPaste
16792}
16793
16794fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16795    if text.len() > MAX_NODE_TEXT_BYTES {
16796        return Err(failure(
16797            NodeFailureCode::InvalidRequest,
16798            &format!(
16799                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16800                text.len(),
16801            ),
16802        ));
16803    }
16804    Ok(())
16805}
16806
16807fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16808    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16809        return Err(failure(
16810            NodeFailureCode::InvalidRequest,
16811            &format!(
16812                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16813                bytes.len(),
16814            ),
16815        ));
16816    }
16817    Ok(())
16818}
16819
16820fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16821    validate_terminal_bytes(&bytes)?;
16822    Ok(InputAction::TerminalBytes(bytes))
16823}
16824
16825fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16826    if root.is_empty()
16827        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16828        || root.chars().any(char::is_control)
16829    {
16830        return Err(failure(
16831            NodeFailureCode::InvalidWorkspaceRoot,
16832            &format!(
16833                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16834            ),
16835        ));
16836    }
16837    Ok(())
16838}
16839
16840fn canonical_standalone_workspace_candidate(
16841    workspace_id: &WorkspaceId,
16842    root: &str,
16843) -> Result<String, NodeServerError> {
16844    let path = Path::new(root);
16845    match std::fs::symlink_metadata(path) {
16846        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16847            .map(|workspace| workspace.canonical_root),
16848        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16849            if !path.is_absolute() {
16850                return Err(NodeServerError::InvalidWorkspaceRoot {
16851                    workspace_id: workspace_id.clone(),
16852                    path: root.to_owned(),
16853                    message: "path is not absolute".to_owned(),
16854                });
16855            }
16856            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16857                workspace_id: workspace_id.clone(),
16858                path: root.to_owned(),
16859                message: "path has no standalone directory name".to_owned(),
16860            })?;
16861            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16862                workspace_id: workspace_id.clone(),
16863                path: root.to_owned(),
16864                message: "path has no parent directory".to_owned(),
16865            })?;
16866            let canonical = std::fs::canonicalize(parent)
16867                .map(|parent| parent.join(name))
16868                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16869                    workspace_id: workspace_id.clone(),
16870                    path: root.to_owned(),
16871                    message: error.to_string(),
16872                })?;
16873            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16874                NodeServerError::InvalidWorkspaceRoot {
16875                    workspace_id: workspace_id.clone(),
16876                    path: path.to_string_lossy().into_owned(),
16877                    message: "canonical path is not valid Unicode".to_owned(),
16878                }
16879            })?;
16880            let canonical_root = platform::normalize_canonical_root(canonical_root);
16881            validate_workspace_root(workspace_id, &canonical_root)?;
16882            Ok(canonical_root)
16883        }
16884        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16885            workspace_id: workspace_id.clone(),
16886            path: root.to_owned(),
16887            message: error.to_string(),
16888        }),
16889    }
16890}
16891
16892fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16893    let Some(revision) = revision else {
16894        return Ok(());
16895    };
16896    if revision.is_empty()
16897        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16898        || revision.chars().any(char::is_control)
16899    {
16900        return Err(failure(
16901            NodeFailureCode::InvalidRequest,
16902            &format!(
16903                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16904            ),
16905        ));
16906    }
16907    Ok(())
16908}
16909
16910fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16911    let code = match error.kind {
16912        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16913        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16914        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16915        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16916        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16917        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16918        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16919    };
16920    failure(code, &error.message)
16921}
16922
16923fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16924    let code = match error.kind {
16925        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16926        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16927        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16928            NodeFailureCode::BackendOperationFailed
16929        }
16930        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16931            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16932        }
16933    };
16934    failure(code, error.message)
16935}
16936
16937fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16938    let (code, message) = match error.kind {
16939        GitWorktreeErrorKind::Invalid => (
16940            NodeFailureCode::InvalidRequest,
16941            "managed Git input is invalid",
16942        ),
16943        GitWorktreeErrorKind::NotRepository => (
16944            NodeFailureCode::NotGitRepository,
16945            "managed source is not a Git repository",
16946        ),
16947        GitWorktreeErrorKind::Conflict => (
16948            NodeFailureCode::WorktreeConflict,
16949            "managed Git identity conflicts with existing repository state",
16950        ),
16951        GitWorktreeErrorKind::Protected => (
16952            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16953            "managed worktree ownership validation failed",
16954        ),
16955        GitWorktreeErrorKind::Dirty => (
16956            NodeFailureCode::WorktreeDirty,
16957            "managed worktree contains uncommitted changes",
16958        ),
16959        GitWorktreeErrorKind::Locked => (
16960            NodeFailureCode::WorktreeLocked,
16961            "managed worktree is locked",
16962        ),
16963        GitWorktreeErrorKind::Failed => (
16964            NodeFailureCode::BackendOperationFailed,
16965            "managed Git operation failed",
16966        ),
16967    };
16968    NodeFailure { code, message: message.to_owned() }
16969}
16970
16971fn managed_spawn_receipt(
16972    resolved: &ResolvedSpawnSpec,
16973    incarnation_id: NodeIncarnationId,
16974    session: SessionAddress,
16975    lease: ManagedWorktreeLeaseSnapshot,
16976    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16977    bundle: Option<ResolvedBundleReceipt>,
16978    context: Option<ResolvedContextPackReceipt>,
16979) -> ManagedWorktreeSpawnReceipt {
16980    let mut managed = resolved.clone();
16981    managed.target.worktree_id = Some(lease.workspace_id.clone());
16982    ManagedWorktreeSpawnReceipt {
16983        spawn: managed.receipt_with_materialization(
16984            incarnation_id,
16985            session,
16986            environment_profile,
16987            bundle,
16988            context,
16989        ),
16990        lease,
16991    }
16992}
16993
16994fn validate_selected_worktree(
16995    source_root: &str,
16996    selected_root: &str,
16997    worktrees: &[NativeGitWorktreeSnapshot],
16998) -> Result<(), NodeFailure> {
16999    if !worktrees
17000        .iter()
17001        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17002    {
17003        return Err(failure(
17004            NodeFailureCode::WorktreeProtected,
17005            "source workspace is not an authoritative non-bare Git worktree root",
17006        ));
17007    }
17008    if !worktrees.iter().any(|worktree| {
17009        worktree_paths_equal(&worktree.path, selected_root)
17010            && !worktree.is_main
17011            && !worktree.is_bare
17012    }) {
17013        return Err(failure(
17014            NodeFailureCode::WorktreeProtected,
17015            "selected workspace is not an eligible Git worktree in the source repository",
17016        ));
17017    }
17018    Ok(())
17019}
17020
17021fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17022    let code = match error.kind() {
17023        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17024        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17025            NodeFailureCode::RepositoryPathUnsafe
17026        }
17027        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17028        WorkspaceFileReadErrorKind::NotRegularFile => {
17029            NodeFailureCode::RepositoryFileNotRegular
17030        }
17031        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17032            NodeFailureCode::RepositoryFileReadFailed
17033        }
17034        WorkspaceFileReadErrorKind::RevisionConflict => {
17035            NodeFailureCode::RepositoryFileRevisionConflict
17036        }
17037        WorkspaceFileReadErrorKind::AlreadyExists
17038        | WorkspaceFileReadErrorKind::ParentNotFound
17039        | WorkspaceFileReadErrorKind::ParentNotDirectory
17040        | WorkspaceFileReadErrorKind::Canceled => {
17041            NodeFailureCode::RepositoryFileReadFailed
17042        }
17043    };
17044    failure(code, "repository file read failed")
17045}
17046
17047fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17048    let code = match error.kind() {
17049        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17050        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17051            NodeFailureCode::RepositoryPathUnsafe
17052        }
17053        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17054        WorkspaceFileReadErrorKind::NotRegularFile => {
17055            NodeFailureCode::RepositoryFileNotRegular
17056        }
17057        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17058            NodeFailureCode::RepositoryFileWriteFailed
17059        }
17060        WorkspaceFileReadErrorKind::RevisionConflict => {
17061            NodeFailureCode::RepositoryFileRevisionConflict
17062        }
17063        WorkspaceFileReadErrorKind::AlreadyExists
17064        | WorkspaceFileReadErrorKind::ParentNotFound
17065        | WorkspaceFileReadErrorKind::ParentNotDirectory
17066        | WorkspaceFileReadErrorKind::Canceled => {
17067            NodeFailureCode::RepositoryFileWriteFailed
17068        }
17069    };
17070    failure(code, "repository file write failed")
17071}
17072
17073async fn settle_workspace_entry_create<T>(
17074    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17075    commit_state: Arc<AtomicU8>,
17076    deadline: Duration,
17077    timeout_message: &'static str,
17078    task_failure_message: &'static str,
17079) -> Result<T, NodeFailure> {
17080    let joined = match timeout(deadline, &mut task).await {
17081        Ok(joined) => joined,
17082        Err(_) => {
17083            if commit_state
17084                .compare_exchange(
17085                    WORKSPACE_ENTRY_CREATE_PENDING,
17086                    WORKSPACE_ENTRY_CREATE_CANCELED,
17087                    Ordering::AcqRel,
17088                    Ordering::Acquire,
17089                )
17090                .is_ok()
17091            {
17092                return Err(failure(
17093                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17094                    timeout_message,
17095                ));
17096            }
17097            debug_assert_eq!(
17098                commit_state.load(Ordering::Acquire),
17099                WORKSPACE_ENTRY_CREATE_COMMITTING,
17100            );
17101            task.await
17102        }
17103    };
17104    joined
17105        .map_err(|_| {
17106            failure(
17107                NodeFailureCode::RepositoryEntryCreateFailed,
17108                task_failure_message,
17109            )
17110        })?
17111        .map_err(workspace_entry_create_failure)
17112}
17113
17114fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17115    let code = match error.kind() {
17116        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17117        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17118            NodeFailureCode::RepositoryPathUnsafe
17119        }
17120        WorkspaceFileReadErrorKind::AlreadyExists => {
17121            NodeFailureCode::RepositoryEntryAlreadyExists
17122        }
17123        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17124            NodeFailureCode::RepositoryParentNotFound
17125        }
17126        WorkspaceFileReadErrorKind::ParentNotDirectory
17127        | WorkspaceFileReadErrorKind::NotRegularFile => {
17128            NodeFailureCode::RepositoryParentNotDirectory
17129        }
17130        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17131            NodeFailureCode::RepositoryEntryCreateFailed
17132        }
17133        WorkspaceFileReadErrorKind::RevisionConflict => {
17134            NodeFailureCode::RepositoryEntryCreateFailed
17135        }
17136        WorkspaceFileReadErrorKind::Canceled => {
17137            NodeFailureCode::RepositoryEntryCreateTimedOut
17138        }
17139    };
17140    failure(code, "repository entry create failed")
17141}
17142
17143fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17144    let value = digest(&SHA256, bytes)
17145        .as_ref()
17146        .iter()
17147        .map(|byte| format!("{byte:02x}"))
17148        .collect();
17149    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17150}
17151
17152fn managed_spawn_request_digest_v2(
17153    request: &ManagedWorktreeSpawnRequestV2,
17154) -> Result<String, NodeFailure> {
17155    let encoded = serde_json::to_vec(request).map_err(|_| {
17156        failure(
17157            NodeFailureCode::InvalidRequest,
17158            "managed worktree V2 request could not be canonicalized",
17159        )
17160    })?;
17161    let mut value = String::with_capacity(71);
17162    value.push_str("sha256:");
17163    for byte in digest(&SHA256, &encoded).as_ref() {
17164        use std::fmt::Write as _;
17165        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17166    }
17167    Ok(value)
17168}
17169
17170fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17171    let code = match error {
17172        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17173        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17174        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17175        NativeSessionPreviewError::UnsupportedProvider
17176        | NativeSessionPreviewError::PreviewUnavailable
17177        | NativeSessionPreviewError::SessionNotFound
17178        | NativeSessionPreviewError::AmbiguousSession => {
17179            NodeFailureCode::BackendOperationFailed
17180        }
17181    };
17182    failure(code, "native session preview failed")
17183}
17184
17185fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17186    let code = match error.kind() {
17187        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17188        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17189    };
17190    failure(code, "host directory browse failed")
17191}
17192
17193fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17194    let code = match error {
17195        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17196        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17197        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17198            NodeFailureCode::DeliveryStageConflict
17199        }
17200        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17201        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17202            NodeFailureCode::DeliveryChunkOutOfOrder
17203        }
17204        DeliveryStoreError::BlobDigestMismatch => {
17205            NodeFailureCode::DeliveryBlobDigestMismatch
17206        }
17207        DeliveryStoreError::BundleDigestMismatch => {
17208            NodeFailureCode::DeliveryBundleDigestMismatch
17209        }
17210        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17211        DeliveryStoreError::Unavailable
17212        | DeliveryStoreError::Corrupt
17213        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17214    };
17215    failure(code, "delivery operation failed")
17216}
17217
17218fn validate_workspace_root(
17219    workspace_id: &WorkspaceId,
17220    canonical_root: &str,
17221) -> Result<(), NodeServerError> {
17222    #[cfg(windows)]
17223    if canonical_root.starts_with(r"\\") {
17224        return Err(NodeServerError::InvalidWorkspaceRoot {
17225            workspace_id: workspace_id.clone(),
17226            path: canonical_root.to_owned(),
17227            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17228        });
17229    }
17230    if !platform::workspace_root_supported(canonical_root)
17231        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17232    {
17233        return Err(NodeServerError::InvalidWorkspaceRoot {
17234            workspace_id: workspace_id.clone(),
17235            path: canonical_root.to_owned(),
17236            message: "canonical path must be absolute, bounded, and free of control characters"
17237                .to_owned(),
17238        });
17239    }
17240    Ok(())
17241}
17242
17243fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17244    NodeFailure {
17245        code,
17246        message: node_failure_category(code).to_owned(),
17247    }
17248}
17249
17250fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17251    let code = match error {
17252        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17253        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17254        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17255        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17256        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17257        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17258        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17259        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17260        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17261    };
17262    failure(code, "harness MCP proxy operation failed")
17263}
17264
17265/// Numbers each harness-MCP overlay this process constructs, in
17266/// construction order, so two overlays built back to back in one node process
17267/// still get distinct trace files.
17268static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17269
17270/// Builds the optional trace environment entry of one harness-MCP overlay.
17271/// The caller's launch description names both the entry and the node-process
17272/// variable that opts in: when that variable holds a non-empty directory, the
17273/// entry names a trace file under it. Returns `None` when the launch has no
17274/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17275/// the directory; that is the operator's job.
17276fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17277    let trace = launch.trace.as_ref()?;
17278    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17279    if trace_dir.is_empty() {
17280        return None;
17281    }
17282    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17283    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17284    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17285}
17286
17287fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17288    let remaining = deadline.saturating_duration_since(Instant::now());
17289    if remaining.is_zero() {
17290        Err(failure(
17291            NodeFailureCode::SpawnDeadlineExceeded,
17292            "spawn deadline elapsed",
17293        ))
17294    } else {
17295        Ok(remaining)
17296    }
17297}
17298
17299fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17300    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17301    match deadline {
17302        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17303        None => Ok(bounded),
17304    }
17305}
17306
17307fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17308    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17309        failure(
17310            NodeFailureCode::SpawnDeadlineExceeded,
17311            "spawn dispatch exceeded the spawn deadline",
17312        )
17313    } else {
17314        error
17315    }
17316}
17317
17318fn spawn_runtime_capabilities(
17319    capabilities: &SpawnRequiredCapabilities,
17320) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17321    capabilities
17322        .iter()
17323        .map(|capability| match capability.as_str() {
17324            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17325            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17326            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17327            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17328                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17329            }
17330            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17331            _ => Err(failure(
17332                NodeFailureCode::UnsupportedSpawnCapability,
17333                "spawn required capability is unknown",
17334            )),
17335        })
17336        .collect()
17337}
17338
17339fn persistence_failure(_error: io::Error) -> NodeFailure {
17340    NodeFailure {
17341        code: NodeFailureCode::BackendOperationFailed,
17342        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17343    }
17344}
17345
17346fn node_failure_category(code: NodeFailureCode) -> &'static str {
17347    match code {
17348        NodeFailureCode::InvalidRequest => "invalid-request",
17349        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17350        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17351        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17352        NodeFailureCode::ReservationConflict => "reservation-conflict",
17353        NodeFailureCode::ReservationExpired => "reservation-expired",
17354        NodeFailureCode::BindingMismatch => "binding-mismatch",
17355        NodeFailureCode::NotActivated => "not-activated",
17356        NodeFailureCode::CallNotFound => "call-not-found",
17357        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17358        NodeFailureCode::ResponseTooLarge => "response-too-large",
17359        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17360        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17361        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17362        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17363        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17364        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17365        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17366        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17367        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17368        NodeFailureCode::Unauthorized => "unauthorized",
17369        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17370        NodeFailureCode::ControllerBusy => "controller-busy",
17371        NodeFailureCode::ControllerRequired => "controller-required",
17372        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17373        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17374        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17375        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17376        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17377        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17378        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17379        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17380        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17381        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17382        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17383        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17384        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17385        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17386        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17387        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17388        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17389        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17390        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17391        NodeFailureCode::GitReadFailed => "git-read-failed",
17392        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17393        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17394        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17395        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17396        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17397        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17398        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17399        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17400        NodeFailureCode::TurnInFlight => "turn-in-flight",
17401        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17402        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17403        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17404            "unsupported-network-allowlist-mapping"
17405        }
17406        NodeFailureCode::BrowserStationProbeUnavailable => {
17407            "browser-station-probe-unavailable"
17408        }
17409        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17410        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17411        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17412            "environment-profile-binding-mismatch"
17413        }
17414        NodeFailureCode::UnknownBundle => "unknown-bundle",
17415        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17416        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17417        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17418        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17419        NodeFailureCode::ContextPackMaterializationFailed => {
17420            "context-pack-materialization-failed"
17421        }
17422        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17423        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17424        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17425        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17426        NodeFailureCode::LastWorkspace => "last-workspace",
17427        NodeFailureCode::NotGitRepository => "not-git-repository",
17428        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17429        NodeFailureCode::WorktreeProtected => "worktree-protected",
17430        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17431        NodeFailureCode::WorktreeLocked => "worktree-locked",
17432        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17433        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17434        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17435        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17436            "managed-worktree-profile-revision-mismatch"
17437        }
17438        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17439        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17440            "standalone-workspace-recovery-required"
17441        }
17442        NodeFailureCode::UnknownSession => "unknown-session",
17443        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17444        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17445        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17446        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17447        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17448        NodeFailureCode::WorkspaceRegistrationRequired => {
17449            "workspace-registration-required"
17450        }
17451        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17452        NodeFailureCode::StaleGeneration => "stale-generation",
17453        NodeFailureCode::BackendBusy => "backend-busy",
17454        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17455        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17456        NodeFailureCode::ShuttingDown => "shutting-down",
17457    }
17458}
17459
17460fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17461    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17462}
17463
17464fn durable_state_load_error(error: io::Error) -> NodeServerError {
17465    let category = match session_registry::state_load_refusal(&error) {
17466        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17467            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17468        }
17469        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17470            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17471        }
17472        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17473            DURABLE_STATE_CONFLICT_ERROR
17474        }
17475        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17476    };
17477    durable_state_server_error(error, category)
17478}
17479
17480fn unix_time_ms() -> u64 {
17481    std::time::SystemTime::now()
17482        .duration_since(std::time::UNIX_EPOCH)
17483        .unwrap_or_default()
17484        .as_millis()
17485        .min(u64::MAX as u128) as u64
17486}
17487
17488fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17489    if record.provider_session.is_some() {
17490        ManagedSessionState::Dormant
17491    } else {
17492        ManagedSessionState::Unavailable
17493    }
17494}
17495
17496fn active_record_state(
17497    identity_admitted: bool,
17498    provider_session_is_known: bool,
17499) -> ManagedSessionState {
17500    if identity_admitted && !provider_session_is_known {
17501        ManagedSessionState::IdentityPending
17502    } else {
17503        ManagedSessionState::Live
17504    }
17505}
17506
17507fn managed_worktree_record_holders(
17508    records: &[ManagedSessionRecord],
17509) -> Vec<(SessionRecordId, WorkspaceId)> {
17510    records
17511        .iter()
17512        .filter(|record| record.provider_session.is_some())
17513        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17514        .collect()
17515}
17516
17517fn managed_spawn_record_ownership(
17518    binding: &SessionBinding,
17519    effective_runtime_policy: ProviderRuntimePolicy,
17520) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17521    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17522    if effective_runtime_policy.provider_session_identity {
17523        (binding.record_id.clone(), None)
17524    } else {
17525        (None, binding.record_id.clone())
17526    }
17527}
17528
17529#[derive(Debug, Error)]
17530pub enum NodeServerError {
17531    #[error("node endpoint must be a bounded absolute local transport path")]
17532    InvalidEndpoint,
17533    #[error("node access token must contain 1..=4096 bytes")]
17534    InvalidAccessToken,
17535    #[error("node HTTP observer must listen on a loopback address: {0}")]
17536    InvalidApiListen(std::net::SocketAddr),
17537    #[error("node browser bridge must listen on a loopback address: {0}")]
17538    InvalidBridgeListen(std::net::SocketAddr),
17539    #[error("node browser bridge token must contain 1..=4096 bytes")]
17540    InvalidBridgeToken,
17541    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17542    BridgeUnderlayRequiresBridgeListen,
17543    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17544    BridgeUnderlayRequiresBridgeToken,
17545    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17546    InvalidBridgeUnderlayToken,
17547    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17548    InvalidBridgeUnderlayKey,
17549    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17550    InvalidCallHome(std::net::SocketAddr),
17551    #[error("node could not reach its call-home relay: {0}")]
17552    CallHomeTransport(#[source] std::io::Error),
17553    #[error("node durable state path must be an absolute file path: {0}")]
17554    InvalidStatePath(String),
17555    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17556    InvalidHarnessMcpHelper,
17557    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17558    InvalidNetworkAllowlistCatalogPath(String),
17559    #[error("network allowlist catalog could not be read: {0}")]
17560    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17561    #[error("network allowlist catalog line {line}: {message}")]
17562    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17563    #[error("network allowlist catalog: {0}")]
17564    InvalidNetworkAllowlistCatalog(String),
17565    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17566    NetworkAllowlistCatalogCapacity { max: usize },
17567    #[error("the local state directory is unavailable; supply an explicit state path")]
17568    LocalStateDirectoryUnavailable,
17569    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17570    LocalRuntimeDirectoryUnavailable,
17571    #[error("node durable state failed: {0}")]
17572    DurableState(io::Error),
17573    #[error("node requires at least one configured workspace")]
17574    NoWorkspaces,
17575    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17576    InvalidWorkspaceRoot {
17577        workspace_id: WorkspaceId,
17578        path: String,
17579        message: String,
17580    },
17581    #[error("duplicate workspace ID: {0}")]
17582    DuplicateWorkspaceId(WorkspaceId),
17583    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17584    DuplicateWorkspaceRoot {
17585        first: WorkspaceId,
17586        second: WorkspaceId,
17587        root: String,
17588    },
17589    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17590    ManagedWorktreeProfileRequired(WorkspaceId),
17591    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17592    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17593    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17594    DuplicateManagedWorktreeProfile {
17595        workspace_id: WorkspaceId,
17596        profile_id: WorktreeProfileId,
17597    },
17598    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17599    ManagedWorktreeProfileCapacity {
17600        workspace_id: WorkspaceId,
17601        max: usize,
17602    },
17603    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17604    InvalidManagedWorktreeProfile {
17605        workspace_id: WorkspaceId,
17606        profile_id: WorktreeProfileId,
17607        message: String,
17608    },
17609    #[error("duplicate node environment profile: {0}")]
17610    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17611    #[error("node environment profile provider is unavailable: {0}")]
17612    EnvironmentProfileProviderUnavailable(AgentId),
17613    #[error("node environment profile capacity is {max}")]
17614    EnvironmentProfileCapacity { max: usize },
17615    #[error("node environment profiles reuse native profile ID '{0}'")]
17616    DuplicateNativeEnvironmentProfile(String),
17617    #[error("node native environment profile is invalid: {0}")]
17618    NativeEnvironmentProfile(String),
17619    #[error("node session-environment materialization root must be absolute")]
17620    InvalidSessionEnvironmentRoot,
17621    #[error("node session-environment materializer is unavailable")]
17622    SessionEnvironmentMaterializerRequired,
17623    #[error("node session-environment materializer failed to initialize")]
17624    SessionEnvironmentMaterializer,
17625    #[error("node bundle catalog is invalid: {0}")]
17626    BundleCatalog(String),
17627    #[error("node delivery store failed to initialize: {0}")]
17628    DeliveryStore(String),
17629    #[error("node context pack store failed to initialize")]
17630    ContextPackStore,
17631    #[error("active agent registry failed: {0}")]
17632    Registry(String),
17633    #[error("node provider contract manifest is invalid: {0}")]
17634    ProviderContractManifest(String),
17635    #[error("named pipe I/O failed: {0}")]
17636    Io(#[from] io::Error),
17637    #[error("node HTTP observer failed: {0}")]
17638    HttpApi(io::Error),
17639    #[error("node browser bridge failed: {0}")]
17640    BridgeApi(io::Error),
17641    #[error(transparent)]
17642    Frame(#[from] FrameError),
17643    #[error("node handshake failed: {0}")]
17644    Handshake(String),
17645    #[error("node authentication frame was not received before the bounded deadline")]
17646    AuthenticationTimedOut,
17647    #[error("node authentication primitive failed: {0}")]
17648    Authentication(String),
17649    #[error("node incarnation identity generation failed: {0}")]
17650    IncarnationIdentity(String),
17651    #[error("node pre-authentication limiter is closed")]
17652    PreauthClosed,
17653    #[error("node authenticated connection limit was reached")]
17654    AuthenticatedConnectionLimit,
17655    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17656    SignalStreamClosed,
17657    #[error("node stop-all dispatch failed: {0:?}")]
17658    ShutdownDispatch(NodeFailure),
17659    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17660    ShutdownTimedOut { active_native_sessions: usize },
17661}
17662
17663impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17664    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17665        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17666        match error {
17667            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17668            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17669            E::InvalidEntry { line, message } => {
17670                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17671            }
17672            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17673            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17674        }
17675    }
17676}
17677
17678#[cfg(test)]
17679mod agent_stream_blocked_tests {
17680    use super::*;
17681    use gate4agent_types::{
17682        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17683        ProviderSource,
17684    };
17685
17686    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17687        ProviderSource {
17688            family,
17689            binding: AdapterBinding::new(
17690                AdapterId::new(adapter).unwrap(),
17691                "fixture/v1",
17692                AdapterVerification::SyntheticFixture,
17693            )
17694            .unwrap(),
17695        }
17696    }
17697
17698    fn provider_control_event_at(
17699        family: AdapterFamily,
17700        adapter: &str,
17701        source_sequence: u64,
17702        provider_event: ProviderEvent,
17703    ) -> ControlEvent {
17704        ControlEvent {
17705            sequence: 41,
17706            command_id: None,
17707            instance_id: AgentInstanceId(7),
17708            generation: SessionGeneration(3),
17709            event: ControlEventKind::ProviderEvent {
17710                sequence: 8,
17711                source: provider_source(family, adapter),
17712                source_sequence,
17713                event: provider_event,
17714            },
17715        }
17716    }
17717
17718    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17719        provider_control_event_at(family, "fixture", 9, provider_event)
17720    }
17721
17722    fn denied_host_request(
17723        by: ProviderHostDecisionAuthority,
17724        reason: Option<String>,
17725    ) -> ControlEvent {
17726        provider_control_event_at(
17727            AdapterFamily::Acp,
17728            "claude-code",
17729            2,
17730            ProviderEvent::HostRequestObserved {
17731                method: "terminal/create".to_owned(),
17732                params_json: String::new(),
17733                decision: ProviderHostRequestDecision::Denied { by },
17734                outcome: ProviderHostRequestOutcome::Executed,
17735                reason,
17736            },
17737        )
17738    }
17739
17740    fn blocked(
17741        event: &ControlEvent,
17742    ) -> (
17743        Option<String>,
17744        String,
17745        BlockAuthorityV1,
17746        Option<String>,
17747        String,
17748        Option<String>,
17749    ) {
17750        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17751        chunk.validate().expect("minted chunk must validate");
17752        let AgentStreamChunkKindV1::Blocked {
17753            correlation_id,
17754            tool_class,
17755            authority,
17756            reason_kind,
17757            reason,
17758            help,
17759        } = chunk.kind
17760        else {
17761            panic!("expected AgentStreamChunkKindV1::Blocked");
17762        };
17763        (correlation_id, tool_class, authority, reason_kind, reason, help)
17764    }
17765
17766    /// Pins the fleet and every adapter each member declares: the fleet is
17767    /// exactly what `active_registry` says, and no member declares a hook
17768    /// adapter (lifecycle hooks are retired).
17769    #[test]
17770    fn the_enabled_provider_matrix_is_exact() {
17771        let registry = active_registry().unwrap();
17772        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17773        assert_eq!(
17774            providers
17775                .iter()
17776                .map(|contract| contract.provider.as_str())
17777                .collect::<Vec<_>>(),
17778            vec!["claude", "codex", "grok", "kimi"],
17779        );
17780        assert!(adapters.iter().all(|contract| {
17781            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17782        }));
17783    }
17784
17785    /// A gate-denied host request, WITH its reason text carried all the way
17786    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17787    #[test]
17788    fn gate_denied_host_request_mints_a_blocked_chunk() {
17789        let gate_text =
17790            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17791        let event = denied_host_request(
17792            ProviderHostDecisionAuthority::Gate,
17793            Some(gate_text.to_owned()),
17794        );
17795        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17796        assert_eq!(chunk.source_sequence, 2);
17797        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17798        assert_eq!(authority, BlockAuthorityV1::HostGate);
17799        assert_eq!(reason, gate_text);
17800        assert_eq!(correlation_id, None);
17801        assert_eq!(reason_kind, None);
17802        assert_eq!(help, None);
17803        assert_eq!(tool_class, "Write");
17804    }
17805
17806    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.";
17807
17808    /// The owner's classifier sample, verbatim, arriving as a
17809    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17810    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17811    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17812    /// and the guidance tail as `help`.
17813    #[test]
17814    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17815        let event = provider_control_event(
17816            AdapterFamily::Acp,
17817            ProviderEvent::ToolCompleted {
17818                id: "toolu_9".to_owned(),
17819                output: CLASSIFIER_SAMPLE.to_owned(),
17820                is_error: true,
17821                duration_ms: None,
17822                agent_id: None,
17823                non_execution_kind: Some("permission-rule".to_owned()),
17824            },
17825        );
17826        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17827        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17828        assert_eq!(reason, "Blocked by classifier");
17829        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17830        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17831        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17832    }
17833
17834    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17835    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17836    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17837    /// vendor code as `reason_kind`.
17838    #[test]
17839    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17840        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.";
17841        let event = provider_control_event(
17842            AdapterFamily::Acp,
17843            ProviderEvent::SessionEnded {
17844                result: "JSON-RPC error -32603: Internal error".to_owned(),
17845                cost_usd: None,
17846                is_error: true,
17847                stop_reason: Some(ProviderStopReason::ProviderError {
17848                    code: -32603,
17849                    message: message.to_owned(),
17850                    vendor_code: Some("usageLimitExceeded".to_owned()),
17851                }),
17852            },
17853        );
17854        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17855        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17856        assert_eq!(reason, message);
17857        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17858        assert_eq!(tool_class, "Session");
17859    }
17860
17861    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17862    /// `authority: ProviderRefusal`.
17863    #[test]
17864    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17865        let event = provider_control_event(
17866            AdapterFamily::Acp,
17867            ProviderEvent::SessionEnded {
17868                result: "refusal".to_owned(),
17869                cost_usd: None,
17870                is_error: true,
17871                stop_reason: Some(ProviderStopReason::Refusal),
17872            },
17873        );
17874        let (_, _, authority, _, _, _) = blocked(&event);
17875        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17876    }
17877
17878    /// A failed tool completion with no typed `non_execution_kind` -- or one
17879    /// that only says the turn stopped -- is a plain execution failure, never a
17880    /// block; nor is an agent's own narrative claiming one.
17881    #[test]
17882    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17883        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17884            let event = provider_control_event(
17885                AdapterFamily::Acp,
17886                ProviderEvent::ToolCompleted {
17887                    id: "toolu_1".to_owned(),
17888                    output: "command exited with status 1".to_owned(),
17889                    is_error: true,
17890                    duration_ms: Some(12),
17891                    agent_id: None,
17892                    non_execution_kind,
17893                },
17894            );
17895            assert!(agent_stream_chunk(&event).is_none());
17896        }
17897        let narrative = provider_control_event(
17898            AdapterFamily::Acp,
17899            ProviderEvent::ToolCompleted {
17900                id: "toolu_2".to_owned(),
17901                output: "the harness blocked the write, approval needed".to_owned(),
17902                is_error: false,
17903                duration_ms: Some(3),
17904                agent_id: None,
17905                non_execution_kind: None,
17906            },
17907        );
17908        assert!(agent_stream_chunk(&narrative).is_none());
17909    }
17910
17911    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17912    /// execution outcome: a granted request that failed while running was
17913    /// still authorized.
17914    #[test]
17915    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17916        for (decision, outcome) in [
17917            (
17918                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17919                ProviderHostRequestOutcome::Executed,
17920            ),
17921            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17922            (
17923                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17924                ProviderHostRequestOutcome::Failed {
17925                    error: "terminal/create spawn failed: os error 3".to_owned(),
17926                },
17927            ),
17928        ] {
17929            let event = provider_control_event_at(
17930                AdapterFamily::Acp,
17931                "claude-code",
17932                3,
17933                ProviderEvent::HostRequestObserved {
17934                    method: "fs/read_text_file".to_owned(),
17935                    params_json: String::new(),
17936                    decision: decision.clone(),
17937                    outcome,
17938                    reason: None,
17939                },
17940            );
17941            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17942        }
17943    }
17944
17945    /// A deferred `session/request_permission` resolved by an operator or an
17946    /// expired deadline carries no reason text -- the chunk falls back to a
17947    /// categorical sentence naming the authority rather than inventing detail.
17948    #[test]
17949    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17950        for (by, expected_authority, expected_reason) in [
17951            (
17952                ProviderHostDecisionAuthority::Operator,
17953                BlockAuthorityV1::Operator,
17954                "declined by the operator",
17955            ),
17956            (
17957                ProviderHostDecisionAuthority::DeadlinePolicy,
17958                BlockAuthorityV1::HostDeadline,
17959                "denied by host policy after the operator deadline expired",
17960            ),
17961        ] {
17962            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17963            assert_eq!(authority, expected_authority);
17964            assert_eq!(reason, expected_reason);
17965        }
17966    }
17967
17968    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17969    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17970    /// outright rather than truncating.
17971    #[test]
17972    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17973        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17974        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17975            ProviderHostDecisionAuthority::Policy,
17976            Some(oversized),
17977        ));
17978        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17979    }
17980}
17981
17982#[cfg(test)]
17983mod standalone_capability_platform_tests {
17984    use super::*;
17985
17986    #[test]
17987    fn standalone_workspace_capability_requires_windows_identity_guards() {
17988        let capabilities = baseline_capabilities().unwrap();
17989        let advertised = capabilities.iter().any(|capability| {
17990            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17991        });
17992        assert_eq!(advertised, cfg!(windows));
17993
17994        let request = NodeRequest::CreateStandaloneWorkspace {
17995            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17996            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17997            initial_branch: None,
17998        };
17999        assert_eq!(
18000            request_uses_unnegotiated_capability(&request, &capabilities),
18001            !cfg!(windows),
18002        );
18003    }
18004}
18005
18006
18007#[cfg(test)]
18008mod station_knobs_resolve_tests {
18009    use super::*;
18010    use gate4agent_catalog::EnvMutation;
18011    use gate4agent_runtime_native::{
18012        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18013        NativeLaunchProfileId,
18014    };
18015    use std::ffi::OsString;
18016    use std::sync::Arc;
18017
18018    struct EmptyEnvironmentResolver;
18019
18020    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18021        fn resolve_child_environment(
18022            &self,
18023        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18024            Ok(vec![EnvMutation {
18025                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18026                value: None,
18027            }])
18028        }
18029    }
18030
18031    fn agent(value: &str) -> AgentId {
18032        AgentId::new(value).unwrap()
18033    }
18034
18035    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18036        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18037        let profile_revision =
18038            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18039        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18040            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18041            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18042            provider: agent("claude"),
18043            mode: SessionMode::Pty,
18044            terminal_size: gate4agent_types::TerminalSize {
18045                rows: 24,
18046                columns: 80,
18047            },
18048            prompt: None,
18049            bundle_id: None,
18050            context_id: None,
18051            environment_profile_id: Some(profile_id.clone()),
18052        };
18053        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18054        let catalog = active_registry().unwrap();
18055        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18056        let control = runtime.native_launch_profile_control();
18057        let node_profile = NodeEnvironmentProfile::new(
18058            profile_id.clone(),
18059            profile_revision,
18060            agent("claude"),
18061            [NativeLaunchProfile::new(
18062                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18063                agent("claude"),
18064                TransportKind::Pty,
18065                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18066                Arc::new(EmptyEnvironmentResolver),
18067            )
18068            .unwrap()],
18069        )
18070        .unwrap();
18071        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18072        for native_profile in native_profiles {
18073            runtime.upsert_native_launch_profile(native_profile).unwrap();
18074        }
18075        let workspace = WorkspaceConfig::new(
18076            WorkspaceId::new("primary").unwrap(),
18077            std::env::current_dir().unwrap(),
18078        )
18079        .unwrap();
18080        let shared = NodeShared::new_with_incarnation(
18081            handle,
18082            "fixture-token".to_owned(),
18083            NodeId::new("node-station-knobs-test").unwrap(),
18084            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18085            vec![workspace],
18086            vec![agent("claude")],
18087            ProviderRuntimeStatuses::default(),
18088            None,
18089            Vec::new(),
18090            Vec::new(),
18091            spawn_profiles,
18092            Some(control),
18093            None,
18094            None,
18095            None,
18096            Vec::new(),
18097            Vec::new(),
18098            Vec::new(),
18099            Vec::new(),
18100            None,
18101            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18102        );
18103        shared
18104            .environment_profiles
18105            .write()
18106            .unwrap_or_else(|poisoned| poisoned.into_inner())
18107            .insert(binding.id.clone(), binding);
18108        (shared, spawn_defaults)
18109    }
18110
18111    /// Empty-default catalog refuses unknown network_allowlist ids.
18112    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18113    #[test]
18114    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18115        let (shared, defaults) = station_knobs_fixture();
18116        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18117        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18118        let spec = crate::protocol::SpawnSpec {
18119            target: crate::protocol::SpawnTarget {
18120                node_id: shared.node_id.clone(),
18121                workspace_id: WorkspaceId::new("primary").unwrap(),
18122                worktree_id: None,
18123            },
18124            profile_id: defaults.profile_id.clone(),
18125            expected_profile_revision: defaults.revision.clone(),
18126            overrides: crate::protocol::SpawnOverrides {
18127                network_allowlist: Some(unknown),
18128                browser_profile_id: Some(browser),
18129                ..crate::protocol::SpawnOverrides::default()
18130            },
18131            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18132            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18133                "station-knobs-unknown-allowlist",
18134            )
18135            .unwrap(),
18136            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18137        };
18138        let resolved = spec.resolve(&defaults).unwrap();
18139        let failure = shared
18140            .resolve_environment_profile(&resolved)
18141            .expect_err("unknown allowlist must refuse");
18142        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18143    }
18144
18145    /// Registered network_allowlist (+ browser_profile_id when feature off)
18146    /// echo onto the env-profile receipt. Empty catalog still allows omit
18147    /// (None). Empty/whitespace refuse is type-level. Feature-off
18148    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18149    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18150    #[test]
18151    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18152        let (shared, defaults) = station_knobs_fixture();
18153        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18154        #[cfg(not(feature = "dig2-station-probe"))]
18155        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18156        shared
18157            .network_allowlist_catalog
18158            .write()
18159            .unwrap_or_else(|poisoned| poisoned.into_inner())
18160            .insert_id(allowlist.clone())
18161            .unwrap();
18162        let spec = crate::protocol::SpawnSpec {
18163            target: crate::protocol::SpawnTarget {
18164                node_id: shared.node_id.clone(),
18165                workspace_id: WorkspaceId::new("primary").unwrap(),
18166                worktree_id: None,
18167            },
18168            profile_id: defaults.profile_id.clone(),
18169            expected_profile_revision: defaults.revision.clone(),
18170            overrides: crate::protocol::SpawnOverrides {
18171                network_allowlist: Some(allowlist.clone()),
18172                #[cfg(not(feature = "dig2-station-probe"))]
18173                browser_profile_id: Some(browser.clone()),
18174                ..crate::protocol::SpawnOverrides::default()
18175            },
18176            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18177            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18178                .unwrap(),
18179            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18180        };
18181        let resolved = spec.resolve(&defaults).unwrap();
18182        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18183        #[cfg(not(feature = "dig2-station-probe"))]
18184        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18185        let receipt = shared
18186            .resolve_environment_profile(&resolved)
18187            .expect("resolve ok")
18188            .expect("env profile present");
18189        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18190        #[cfg(not(feature = "dig2-station-probe"))]
18191        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18192        #[cfg(feature = "dig2-station-probe")]
18193        assert_eq!(receipt.browser_profile_id, None);
18194        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18195
18196        let omitted = crate::protocol::SpawnSpec {
18197            overrides: crate::protocol::SpawnOverrides::default(),
18198            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18199                .unwrap(),
18200            ..spec
18201        };
18202        let resolved_omit = omitted.resolve(&defaults).unwrap();
18203        let bare = shared
18204            .resolve_environment_profile(&resolved_omit)
18205            .expect("resolve ok")
18206            .expect("env profile present");
18207        assert_eq!(bare.network_allowlist, None);
18208        assert_eq!(bare.browser_profile_id, None);
18209    }
18210
18211    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18212    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18213    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18214    /// unit-tested separately (probe refuses before lease on Linux).
18215    #[cfg(feature = "dig2-station-probe")]
18216    #[test]
18217    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18218        let (shared, defaults) = station_knobs_fixture();
18219        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18220        let spec = crate::protocol::SpawnSpec {
18221            target: crate::protocol::SpawnTarget {
18222                node_id: shared.node_id.clone(),
18223                workspace_id: WorkspaceId::new("primary").unwrap(),
18224                worktree_id: None,
18225            },
18226            profile_id: defaults.profile_id.clone(),
18227            expected_profile_revision: defaults.revision.clone(),
18228            overrides: crate::protocol::SpawnOverrides {
18229                browser_profile_id: Some(browser),
18230                ..crate::protocol::SpawnOverrides::default()
18231            },
18232            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18233            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18234                "station-knobs-probe-refuse",
18235            )
18236            .unwrap(),
18237            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18238        };
18239        let resolved = spec.resolve(&defaults).unwrap();
18240        let failure = shared
18241            .resolve_environment_profile(&resolved)
18242            .expect_err("browser_profile with failing probe must refuse");
18243        #[cfg(windows)]
18244        {
18245            assert!(
18246                matches!(
18247                    failure.code,
18248                    NodeFailureCode::BrowserStationUnreachable
18249                        | NodeFailureCode::BrowserStationProbeUnavailable
18250                ),
18251                "unexpected code {:?}",
18252                failure.code
18253            );
18254        }
18255        #[cfg(not(windows))]
18256        {
18257            assert_eq!(
18258                failure.code,
18259                NodeFailureCode::BrowserStationProbeUnavailable
18260            );
18261        }
18262    }
18263
18264    /// Feature on: omitting browser_profile_id still resolves (no probe).
18265    #[cfg(feature = "dig2-station-probe")]
18266    #[test]
18267    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18268        let (shared, defaults) = station_knobs_fixture();
18269        let spec = crate::protocol::SpawnSpec {
18270            target: crate::protocol::SpawnTarget {
18271                node_id: shared.node_id.clone(),
18272                workspace_id: WorkspaceId::new("primary").unwrap(),
18273                worktree_id: None,
18274            },
18275            profile_id: defaults.profile_id.clone(),
18276            expected_profile_revision: defaults.revision.clone(),
18277            overrides: crate::protocol::SpawnOverrides::default(),
18278            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18279            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18280                "station-knobs-probe-omit",
18281            )
18282            .unwrap(),
18283            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18284        };
18285        let resolved = spec.resolve(&defaults).unwrap();
18286        let receipt = shared
18287            .resolve_environment_profile(&resolved)
18288            .expect("omit browser_profile must not probe")
18289            .expect("env profile present");
18290        assert_eq!(receipt.browser_profile_id, None);
18291    }
18292
18293    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18294    /// refuses a second holder for the same opaque profile id; release on
18295    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18296    #[cfg(feature = "dig2-station-probe")]
18297    #[test]
18298    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18299        let (shared, _defaults) = station_knobs_fixture();
18300        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18301        let workspace_id = WorkspaceId::new("primary").unwrap();
18302        let address_a = SessionAddress {
18303            workspace_id: workspace_id.clone(),
18304            session: SessionKey {
18305                instance_id: AgentInstanceId(101),
18306                generation: SessionGeneration(1),
18307            },
18308        };
18309        let address_b = SessionAddress {
18310            workspace_id: workspace_id.clone(),
18311            session: SessionKey {
18312                instance_id: AgentInstanceId(102),
18313                generation: SessionGeneration(1),
18314            },
18315        };
18316        let guard_a = crate::dig2_station_lease::acquire_lease(
18317            &shared.browser_station_leases,
18318            profile.clone(),
18319            address_a.session.instance_id,
18320            1,
18321        )
18322        .expect("first lease");
18323        let busy = crate::dig2_station_lease::acquire_lease(
18324            &shared.browser_station_leases,
18325            profile.clone(),
18326            address_b.session.instance_id,
18327            2,
18328        )
18329        .expect_err("second holder must refuse");
18330        assert!(matches!(
18331            busy,
18332            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18333        ));
18334        // Simulate a bound session so remove_binding clears the lease.
18335        shared.bind_session_with_policy(
18336            &address_a,
18337            ProviderRuntimePolicy::raw_pty(),
18338            None,
18339        );
18340        guard_a.retain();
18341        assert!(shared.remove_binding(&address_a).is_some());
18342        crate::dig2_station_lease::acquire_lease(
18343            &shared.browser_station_leases,
18344            profile,
18345            address_b.session.instance_id,
18346            3,
18347        )
18348        .expect("reacquire after clear")
18349        .retain();
18350        assert!(
18351            crate::dig2_station_lease::release_lease_for_holder(
18352                &shared.browser_station_leases,
18353                address_b.session.instance_id,
18354            )
18355        );
18356    }
18357
18358    /// Feature-off: no browser_station_leases field; stub echo documented in
18359    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18360    #[cfg(not(feature = "dig2-station-probe"))]
18361    #[test]
18362    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18363        let (shared, defaults) = station_knobs_fixture();
18364        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18365        let spec = crate::protocol::SpawnSpec {
18366            target: crate::protocol::SpawnTarget {
18367                node_id: shared.node_id.clone(),
18368                workspace_id: WorkspaceId::new("primary").unwrap(),
18369                worktree_id: None,
18370            },
18371            profile_id: defaults.profile_id.clone(),
18372            expected_profile_revision: defaults.revision.clone(),
18373            overrides: crate::protocol::SpawnOverrides {
18374                browser_profile_id: Some(browser.clone()),
18375                ..crate::protocol::SpawnOverrides::default()
18376            },
18377            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18378            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18379                .unwrap(),
18380            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18381        };
18382        let resolved = spec.resolve(&defaults).unwrap();
18383        let receipt = shared
18384            .resolve_environment_profile(&resolved)
18385            .expect("stub resolve")
18386            .expect("env profile");
18387        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18388    }
18389
18390
18391    /// Startup file loader registers ids into the empty-default catalog.
18392    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18393    #[test]
18394    fn network_allowlist_catalog_loads_from_temp_file() {
18395        let unique = std::time::SystemTime::now()
18396            .duration_since(std::time::UNIX_EPOCH)
18397            .unwrap()
18398            .as_nanos();
18399        let path = std::env::temp_dir().join(format!(
18400            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18401            std::process::id(),
18402        ));
18403        std::fs::write(
18404            &path,
18405            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18406        )
18407        .unwrap();
18408        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18409            .expect("temp catalog file must load");
18410        let _ = std::fs::remove_file(&path);
18411        assert_eq!(loaded.len(), 2);
18412        let (shared, defaults) = station_knobs_fixture();
18413        {
18414            let mut catalog = shared
18415                .network_allowlist_catalog
18416                .write()
18417                .unwrap_or_else(|poisoned| poisoned.into_inner());
18418            *catalog = loaded;
18419        }
18420        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18421        let spec = crate::protocol::SpawnSpec {
18422            target: crate::protocol::SpawnTarget {
18423                node_id: shared.node_id.clone(),
18424                workspace_id: WorkspaceId::new("primary").unwrap(),
18425                worktree_id: None,
18426            },
18427            profile_id: defaults.profile_id.clone(),
18428            expected_profile_revision: defaults.revision.clone(),
18429            overrides: crate::protocol::SpawnOverrides {
18430                network_allowlist: Some(allowlist.clone()),
18431                ..crate::protocol::SpawnOverrides::default()
18432            },
18433            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18434            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18435                "station-knobs-catalog-file",
18436            )
18437            .unwrap(),
18438            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18439        };
18440        let resolved = spec.resolve(&defaults).unwrap();
18441        let receipt = shared
18442            .resolve_environment_profile(&resolved)
18443            .expect("registered via file load")
18444            .expect("env profile present");
18445        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18446    }
18447
18448    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18449    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18450    #[test]
18451    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18452        let (shared, _defaults) = station_knobs_fixture();
18453        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18454        shared
18455            .network_allowlist_catalog
18456            .write()
18457            .unwrap_or_else(|poisoned| poisoned.into_inner())
18458            .insert_id(allowlist.clone())
18459            .unwrap();
18460        let snapshot = shared.snapshot();
18461        let inventory = snapshot
18462            .launch_inventory
18463            .expect("launch inventory present");
18464        let ids = inventory
18465            .network_allowlists
18466            .expect("non-empty catalog surfaces ids");
18467        assert_eq!(ids, vec![allowlist]);
18468    }
18469
18470    /// Track B: registered id with provider_native codex_network_access refuses
18471    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18472    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18473    #[test]
18474    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18475        let (shared, defaults) = station_knobs_fixture();
18476        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18477        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18478            id: allowlist.clone(),
18479            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18480                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18481                peer: "127.0.0.1:443".to_owned(),
18482            }],
18483            provider_native: Some(
18484                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18485                    codex_network_access: Some(true),
18486                },
18487            ),
18488        };
18489        shared
18490            .network_allowlist_catalog
18491            .write()
18492            .unwrap_or_else(|poisoned| poisoned.into_inner())
18493            .insert(entry)
18494            .unwrap();
18495        let spec = crate::protocol::SpawnSpec {
18496            target: crate::protocol::SpawnTarget {
18497                node_id: shared.node_id.clone(),
18498                workspace_id: WorkspaceId::new("primary").unwrap(),
18499                worktree_id: None,
18500            },
18501            profile_id: defaults.profile_id.clone(),
18502            expected_profile_revision: defaults.revision.clone(),
18503            overrides: crate::protocol::SpawnOverrides {
18504                network_allowlist: Some(allowlist),
18505                ..crate::protocol::SpawnOverrides::default()
18506            },
18507            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18508            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18509                "station-knobs-unsupported-mapping",
18510            )
18511            .unwrap(),
18512            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18513        };
18514        let resolved = spec.resolve(&defaults).unwrap();
18515        assert_eq!(resolved.provider.as_str(), "claude");
18516        let failure = shared
18517            .resolve_environment_profile(&resolved)
18518            .expect_err("claude + codex_network_access must refuse");
18519        assert_eq!(
18520            failure.code,
18521            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18522        );
18523    }
18524
18525    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18526    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18527    /// Never secrets on C2.
18528    #[test]
18529    fn resolve_codex_network_access_overlay_moderate_only() {
18530        let (shared, defaults) = station_knobs_fixture();
18531        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18532        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18533            id: allowlist.clone(),
18534            permits: Vec::new(),
18535            provider_native: Some(
18536                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18537                    codex_network_access: Some(true),
18538                },
18539            ),
18540        };
18541        shared
18542            .network_allowlist_catalog
18543            .write()
18544            .unwrap_or_else(|poisoned| poisoned.into_inner())
18545            .insert(entry)
18546            .unwrap();
18547
18548        let overlay = shared
18549            .resolve_codex_network_access_overlay_args(
18550                &agent("codex"),
18551                SessionMode::Pty,
18552                ApprovalLevel::Moderate,
18553                Some(&allowlist),
18554            )
18555            .expect("Moderate Codex maps network_access");
18556        assert_eq!(
18557            overlay,
18558            vec![
18559                std::ffi::OsString::from("-c"),
18560                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18561            ]
18562        );
18563
18564        let acp = shared
18565            .resolve_codex_network_access_overlay_args(
18566                &agent("codex"),
18567                SessionMode::Acp,
18568                ApprovalLevel::Moderate,
18569                Some(&allowlist),
18570            )
18571            .unwrap();
18572        assert_eq!(acp, overlay);
18573
18574        let inline = shared
18575            .resolve_codex_network_access_overlay_args(
18576                &agent("codex"),
18577                SessionMode::Inline,
18578                ApprovalLevel::Moderate,
18579                Some(&allowlist),
18580            )
18581            .unwrap_err();
18582        assert_eq!(
18583            inline.code,
18584            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18585        );
18586
18587        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18588            let spec = crate::protocol::SpawnSpec {
18589                target: crate::protocol::SpawnTarget {
18590                    node_id: shared.node_id.clone(),
18591                    workspace_id: WorkspaceId::new("primary").unwrap(),
18592                    worktree_id: None,
18593                },
18594                profile_id: defaults.profile_id.clone(),
18595                expected_profile_revision: defaults.revision.clone(),
18596                overrides: crate::protocol::SpawnOverrides {
18597                    provider: crate::protocol::SpawnOverride::Set {
18598                        value: agent("codex"),
18599                    },
18600                    approval_level: Some(level),
18601                    network_allowlist: Some(allowlist.clone()),
18602                    ..crate::protocol::SpawnOverrides::default()
18603                },
18604                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18605                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18606                    "codex-net-refuse-{}",
18607                    match level {
18608                        ApprovalLevel::ReadOnly => "ro",
18609                        ApprovalLevel::FullAuto => "fa",
18610                        _ => "other",
18611                    }
18612                ))
18613                .unwrap(),
18614                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18615            };
18616            let resolved = spec.resolve(&defaults).unwrap();
18617            let failure = shared
18618                .resolve_environment_profile(&resolved)
18619                .expect_err("RO/FullAuto must refuse");
18620            assert_eq!(
18621                failure.code,
18622                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18623            );
18624        }
18625    }
18626
18627    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18628    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18629    /// Never secrets on C2; does not invent provider_native Claude keys.
18630    #[test]
18631    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18632        let (shared, _defaults) = station_knobs_fixture();
18633        let allowlist =
18634            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18635        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18636            id: allowlist.clone(),
18637            permits: vec![
18638                crate::network_allowlist_catalog::NetworkPermitSketch {
18639                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18640                    peer: "github.com:443".to_owned(),
18641                },
18642                crate::network_allowlist_catalog::NetworkPermitSketch {
18643                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18644                    peer: "*.npmjs.org".to_owned(),
18645                },
18646            ],
18647            provider_native: None,
18648        };
18649        shared
18650            .network_allowlist_catalog
18651            .write()
18652            .unwrap_or_else(|poisoned| poisoned.into_inner())
18653            .insert(entry)
18654            .unwrap();
18655
18656        let unique = std::time::SystemTime::now()
18657            .duration_since(std::time::UNIX_EPOCH)
18658            .unwrap()
18659            .as_nanos();
18660        let provider_home = std::env::temp_dir().join(format!(
18661            "gate4agent-claude-provider-home-{}-{unique}",
18662            std::process::id(),
18663        ));
18664        std::fs::create_dir_all(&provider_home).unwrap();
18665
18666        let overlay = shared
18667            .resolve_network_allowlist_overlay_args(
18668                &agent("claude"),
18669                SessionMode::Pty,
18670                ApprovalLevel::Moderate,
18671                Some(&allowlist),
18672                Some(provider_home.as_path()),
18673            )
18674            .expect("Claude PTY settings overlay on unix");
18675        let settings_path = provider_home.join(
18676            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18677        );
18678        assert_eq!(
18679            overlay,
18680            vec![
18681                std::ffi::OsString::from("--settings"),
18682                std::ffi::OsString::from(settings_path.as_os_str()),
18683            ]
18684        );
18685        let body = std::fs::read_to_string(&settings_path).unwrap();
18686        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18687        assert_eq!(value["sandbox"]["enabled"], true);
18688        assert_eq!(
18689            value["sandbox"]["network"]["allowedDomains"],
18690            serde_json::json!(["github.com", "*.npmjs.org"])
18691        );
18692        assert!(settings_path.starts_with(&provider_home));
18693
18694        let acp = shared
18695            .resolve_network_allowlist_overlay_args(
18696                &agent("claude"),
18697                SessionMode::Acp,
18698                ApprovalLevel::Moderate,
18699                Some(&allowlist),
18700                Some(provider_home.as_path()),
18701            )
18702            .expect_err("Claude ACP must refuse settings overlay");
18703        assert_eq!(
18704            acp.code,
18705            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18706        );
18707
18708        let no_home = shared
18709            .resolve_network_allowlist_overlay_args(
18710                &agent("claude"),
18711                SessionMode::Pty,
18712                ApprovalLevel::Moderate,
18713                Some(&allowlist),
18714                None,
18715            )
18716            .expect_err("Claude overlay requires ProviderHome");
18717        assert_eq!(
18718            no_home.code,
18719            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18720        );
18721
18722        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18723        shared
18724            .network_allowlist_catalog
18725            .write()
18726            .unwrap_or_else(|poisoned| poisoned.into_inner())
18727            .insert_id(bare.clone())
18728            .unwrap();
18729        let empty = shared
18730            .resolve_network_allowlist_overlay_args(
18731                &agent("claude"),
18732                SessionMode::Pty,
18733                ApprovalLevel::Moderate,
18734                Some(&bare),
18735                Some(provider_home.as_path()),
18736            )
18737            .unwrap();
18738        assert!(empty.is_empty());
18739
18740        let _ = std::fs::remove_dir_all(&provider_home);
18741    }
18742
18743    /// Kimi / Grok with permits stay without invented argv overlays.
18744    #[test]
18745    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18746        let (shared, _defaults) = station_knobs_fixture();
18747        let allowlist =
18748            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18749        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18750            id: allowlist.clone(),
18751            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18752                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18753                peer: "example.com:443".to_owned(),
18754            }],
18755            provider_native: None,
18756        };
18757        shared
18758            .network_allowlist_catalog
18759            .write()
18760            .unwrap_or_else(|poisoned| poisoned.into_inner())
18761            .insert(entry)
18762            .unwrap();
18763        for provider in ["kimi", "grok"] {
18764            let overlay = shared
18765                .resolve_network_allowlist_overlay_args(
18766                    &agent(provider),
18767                    SessionMode::Pty,
18768                    ApprovalLevel::Moderate,
18769                    Some(&allowlist),
18770                    None,
18771                )
18772                .unwrap();
18773            assert!(
18774                overlay.is_empty(),
18775                "{provider} must not invent network argv from permits"
18776            );
18777        }
18778    }
18779
18780    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18781    #[test]
18782    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18783        let unique = std::time::SystemTime::now()
18784            .duration_since(std::time::UNIX_EPOCH)
18785            .unwrap()
18786            .as_nanos();
18787        let path = std::env::temp_dir().join(format!(
18788            "gate4agent-node-allowlist-json-{}-{unique}.json",
18789            std::process::id(),
18790        ));
18791        std::fs::write(
18792            &path,
18793            r#"{
18794              "schema_version": 2,
18795              "entries": [
18796                {
18797                  "id": "egress-default",
18798                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18799                },
18800                { "id": "lab-egress" }
18801              ]
18802            }"#,
18803        )
18804        .unwrap();
18805        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18806            .expect("json v2 catalog must load");
18807        let _ = std::fs::remove_file(&path);
18808        assert_eq!(loaded.len(), 2);
18809        let entry = loaded
18810            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18811            .unwrap();
18812        assert_eq!(entry.permits.len(), 1);
18813        let (shared, defaults) = station_knobs_fixture();
18814        {
18815            let mut catalog = shared
18816                .network_allowlist_catalog
18817                .write()
18818                .unwrap_or_else(|poisoned| poisoned.into_inner());
18819            *catalog = loaded;
18820        }
18821        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
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                network_allowlist: Some(allowlist.clone()),
18832                ..crate::protocol::SpawnOverrides::default()
18833            },
18834            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18835            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18836                "station-knobs-catalog-json",
18837            )
18838            .unwrap(),
18839            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18840        };
18841        let resolved = spec.resolve(&defaults).unwrap();
18842        let receipt = shared
18843            .resolve_environment_profile(&resolved)
18844            .expect("permits-only entry ok for claude")
18845            .expect("env profile present");
18846        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18847        let inventory = shared
18848            .snapshot()
18849            .launch_inventory
18850            .expect("launch inventory present");
18851        let ids = inventory.network_allowlists.expect("ids surface");
18852        assert_eq!(
18853            ids,
18854            vec![
18855                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18856                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18857            ]
18858        );
18859    }
18860
18861
18862}
18863
18864
18865#[cfg(test)]
18866mod workspace_environment_collision_tests {
18867    use super::*;
18868    use crate::session_environment::{
18869        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18870    };
18871    use gate4agent_catalog::EnvMutation;
18872    use gate4agent_runtime_native::{
18873        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18874        NativeLaunchProfileId,
18875    };
18876    use std::ffi::OsString;
18877    use std::sync::Arc;
18878
18879    struct EmptyEnvironmentResolver;
18880
18881    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18882        fn resolve_child_environment(
18883            &self,
18884        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18885            Ok(vec![EnvMutation {
18886                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18887                value: None,
18888            }])
18889        }
18890    }
18891
18892    fn agent(value: &str) -> AgentId {
18893        AgentId::new(value).unwrap()
18894    }
18895
18896    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18897    fn claude_config_dir_collision_fixture() -> (
18898        NodeShared,
18899        ResolvedEnvironmentProfileReceipt,
18900        ResolvedEnvironmentProfileReceipt,
18901    ) {
18902        let catalog = active_registry().unwrap();
18903        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18904        let control = runtime.native_launch_profile_control();
18905        let workspace = WorkspaceConfig::new(
18906            WorkspaceId::new("primary").unwrap(),
18907            std::env::current_dir().unwrap(),
18908        )
18909        .unwrap();
18910        let shared = NodeShared::new_with_incarnation(
18911            handle,
18912            "fixture-token".to_owned(),
18913            NodeId::new("node-env-collision-test").unwrap(),
18914            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18915            vec![workspace],
18916            vec![agent("claude")],
18917            ProviderRuntimeStatuses::default(),
18918            None,
18919            Vec::new(),
18920            Vec::new(),
18921            SpawnProfileRegistry::default(),
18922            Some(control),
18923            None,
18924            None,
18925            None,
18926            Vec::new(),
18927            Vec::new(),
18928            Vec::new(),
18929            Vec::new(),
18930            None,
18931            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18932        );
18933
18934        let mut receipts = Vec::new();
18935        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18936            let profile_id =
18937                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18938            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18939                "claude-config-dir-{suffix}-r1"
18940            ))
18941            .unwrap();
18942            let materialization = NodeSessionMaterializationProfile::new(
18943                Vec::new(),
18944                vec![NodeSessionPathBinding::new(
18945                    "CLAUDE_CONFIG_DIR",
18946                    NodeSessionPathClass::ProviderHome,
18947                )
18948                .unwrap()],
18949                Vec::new(),
18950            )
18951            .unwrap();
18952            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18953                profile_id.clone(),
18954                profile_revision.clone(),
18955                agent("claude"),
18956                [NativeLaunchProfile::new(
18957                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18958                        .unwrap(),
18959                    agent("claude"),
18960                    TransportKind::Pty,
18961                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18962                    Arc::new(EmptyEnvironmentResolver),
18963                )
18964                .unwrap()],
18965                Some(materialization),
18966            )
18967            .unwrap();
18968            let (binding, native_profiles, materialization) = node_profile.into_parts();
18969            for native_profile in native_profiles {
18970                runtime.upsert_native_launch_profile(native_profile).unwrap();
18971            }
18972            shared
18973                .environment_profiles
18974                .write()
18975                .unwrap_or_else(|poisoned| poisoned.into_inner())
18976                .insert(binding.id.clone(), binding);
18977            shared
18978                .environment_materialization_profiles
18979                .write()
18980                .unwrap_or_else(|poisoned| poisoned.into_inner())
18981                .insert(profile_id.clone(), materialization.unwrap());
18982            receipts.push(ResolvedEnvironmentProfileReceipt {
18983                profile_id,
18984                profile_revision,
18985                network_allowlist: None,
18986                browser_profile_id: None,
18987            });
18988        }
18989        let profile_b = receipts.pop().unwrap();
18990        let profile_a = receipts.pop().unwrap();
18991        (shared, profile_a, profile_b)
18992    }
18993
18994    #[test]
18995    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18996        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18997        let primary = WorkspaceId::new("primary").unwrap();
18998        let worktree = WorkspaceId::new("worktree-b").unwrap();
18999        let address = SessionAddress {
19000            workspace_id: primary.clone(),
19001            session: SessionKey {
19002                instance_id: AgentInstanceId(41),
19003                generation: SessionGeneration(1),
19004            },
19005        };
19006        shared.bind_session_with_policy(
19007            &address,
19008            ProviderRuntimePolicy::raw_pty(),
19009            Some(profile_a.clone()),
19010        );
19011
19012        let refused = shared
19013            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19014            .unwrap_err();
19015        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19016
19017        // Some vs None on the same cwd is also a collision.
19018        let refused_none = shared
19019            .ensure_workspace_environment_profile_compatible(&primary, None)
19020            .unwrap_err();
19021        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19022
19023        // Same profile on the same workspace remains allowed.
19024        shared
19025            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19026            .unwrap();
19027
19028        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19029        shared
19030            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19031            .unwrap();
19032    }
19033
19034    #[tokio::test]
19035    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19036        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19037        let primary = WorkspaceId::new("primary").unwrap();
19038        let address = SessionAddress {
19039            workspace_id: primary.clone(),
19040            session: SessionKey {
19041                instance_id: AgentInstanceId(41),
19042                generation: SessionGeneration(1),
19043            },
19044        };
19045        shared.bind_session_with_policy(
19046            &address,
19047            ProviderRuntimePolicy::raw_pty(),
19048            Some(profile_a),
19049        );
19050
19051        let failure = shared
19052            .spawn_session_with_deadline(
19053                primary,
19054                agent("claude"),
19055                SessionMode::Pty,
19056                gate4agent_types::TerminalSize {
19057                    rows: 24,
19058                    columns: 80,
19059                },
19060                None,
19061                Some(profile_b),
19062                None,
19063                None,
19064                None,
19065                None,
19066                SpawnRecordPolicy::ProviderIdentityOnly,
19067                Some(Instant::now() + Duration::from_secs(30)),
19068                &[],
19069                None,
19070                ApprovalLevel::default(),
19071                None,
19072                None,
19073            )
19074            .await
19075            .unwrap_err();
19076        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19077        // Guard runs before instance id mint / bind.
19078        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19079        assert!(shared.handle.snapshot().sessions.is_empty());
19080    }
19081}
19082
19083#[cfg(test)]
19084mod spawn_characterization_tests {
19085    use super::*;
19086    use crate::session_environment::{
19087        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19088        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19089        NodeSessionPathClass,
19090    };
19091    use gate4agent_catalog::EnvMutation;
19092    use gate4agent_runtime_native::{
19093        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19094        NativeLaunchProfile, NativeLaunchProfileId,
19095    };
19096    use std::ffi::OsString;
19097    use std::sync::Arc;
19098
19099    struct EmptyEnvironmentResolver;
19100
19101    struct FixtureSecretResolver {
19102        deny: Arc<AtomicBool>,
19103    }
19104
19105    impl NodeSecretResolver for FixtureSecretResolver {
19106        fn resolve(
19107            &self,
19108            _reference: &NodeSecretReference,
19109        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19110            if self.deny.load(Ordering::Acquire) {
19111                Err(NodeSecretResolveError::Denied)
19112            } else {
19113                NodeSecretValue::text("fixture-secret")
19114                    .map_err(|_| NodeSecretResolveError::Unavailable)
19115            }
19116        }
19117    }
19118
19119    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19120        fn resolve_child_environment(
19121            &self,
19122        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19123            Ok(vec![EnvMutation {
19124                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19125                value: None,
19126            }])
19127        }
19128    }
19129
19130    fn agent(value: &str) -> AgentId {
19131        AgentId::new(value).unwrap()
19132    }
19133
19134    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19135        let unique = std::time::SystemTime::now()
19136            .duration_since(std::time::UNIX_EPOCH)
19137            .unwrap()
19138            .as_nanos();
19139        std::env::temp_dir().join(format!(
19140            "gate4agent-node-{label}-{}-{unique}",
19141            std::process::id(),
19142        ))
19143    }
19144
19145    fn materialization_test_shared(
19146        deny_secret: bool,
19147    ) -> (
19148        NodeShared,
19149        NativeRuntime,
19150        ResolvedEnvironmentProfileReceipt,
19151        PathBuf,
19152        Arc<AtomicBool>,
19153    ) {
19154        let workspace = WorkspaceConfig::new(
19155            WorkspaceId::new("primary").unwrap(),
19156            std::env::current_dir().unwrap(),
19157        )
19158        .unwrap();
19159        materialization_test_shared_with_workspace(deny_secret, workspace)
19160    }
19161
19162    fn materialization_test_shared_with_workspace(
19163        deny_secret: bool,
19164        workspace: WorkspaceConfig,
19165    ) -> (
19166        NodeShared,
19167        NativeRuntime,
19168        ResolvedEnvironmentProfileReceipt,
19169        PathBuf,
19170        Arc<AtomicBool>,
19171    ) {
19172        let root = temporary_workspace_root(if deny_secret {
19173            "materialization-denied"
19174        } else {
19175            "materialization-ready"
19176        });
19177        std::fs::create_dir_all(&root).unwrap();
19178        let materialization_root = root.join("session-environments");
19179        let deny = Arc::new(AtomicBool::new(deny_secret));
19180        let resolver = Arc::new(FixtureSecretResolver {
19181            deny: Arc::clone(&deny),
19182        });
19183        let materializer = SessionEnvironmentMaterializer::new(
19184            materialization_root,
19185            resolver,
19186        )
19187        .unwrap();
19188        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19189        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19190            "materialized-claude-r1",
19191        )
19192        .unwrap();
19193        let materialization = NodeSessionMaterializationProfile::new(
19194            vec![NodeSessionEnvironmentMutation::SetSecret {
19195                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19196                reference: NodeSecretReference::new("fixture-token").unwrap(),
19197            }],
19198            vec![NodeSessionPathBinding::new(
19199                "GATE4AGENT_PROVIDER_HOME",
19200                NodeSessionPathClass::ProviderHome,
19201            )
19202            .unwrap()],
19203            vec![NodeSessionFile::secret(
19204                NodeSessionPathClass::Config,
19205                "auth/token",
19206                NodeSecretReference::new("fixture-token").unwrap(),
19207            )
19208            .unwrap()],
19209        )
19210        .unwrap();
19211        let catalog = active_registry().unwrap();
19212        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19213        let control = runtime.native_launch_profile_control();
19214        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19215            profile_id.clone(),
19216            profile_revision.clone(),
19217            agent("claude"),
19218            [NativeLaunchProfile::new(
19219                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19220                agent("claude"),
19221                TransportKind::Pty,
19222                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19223                Arc::new(EmptyEnvironmentResolver),
19224            )
19225            .unwrap()],
19226            Some(materialization),
19227        )
19228        .unwrap();
19229        let (binding, native_profiles, materialization) = node_profile.into_parts();
19230        for native_profile in native_profiles {
19231            runtime.upsert_native_launch_profile(native_profile).unwrap();
19232        }
19233        let shared = NodeShared::new_with_incarnation(
19234            handle,
19235            "fixture-token".to_owned(),
19236            NodeId::new("node-materialization-test").unwrap(),
19237            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19238            vec![workspace],
19239            vec![agent("claude")],
19240            ProviderRuntimeStatuses::default(),
19241            None,
19242            Vec::new(),
19243            Vec::new(),
19244            SpawnProfileRegistry::default(),
19245            Some(control),
19246            None,
19247            Some(materializer),
19248            None,
19249            Vec::new(),
19250            Vec::new(),
19251            Vec::new(),
19252            Vec::new(),
19253            None,
19254            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19255        );
19256        shared
19257            .environment_profiles
19258            .write()
19259            .unwrap_or_else(|poisoned| poisoned.into_inner())
19260            .insert(binding.id.clone(), binding);
19261        shared
19262            .environment_materialization_profiles
19263            .write()
19264            .unwrap_or_else(|poisoned| poisoned.into_inner())
19265            .insert(profile_id.clone(), materialization.unwrap());
19266        (
19267            shared,
19268            runtime,
19269            ResolvedEnvironmentProfileReceipt {
19270                profile_id,
19271                profile_revision,
19272                network_allowlist: None,
19273                browser_profile_id: None,
19274            },
19275            root,
19276            deny,
19277        )
19278    }
19279
19280
19281    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19282
19283    async fn characterization_spawn(
19284        shared: &NodeShared,
19285        mode: SessionMode,
19286        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19287        required: &[ProviderRuntimeCapability],
19288        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19289    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19290        shared
19291            .spawn_session_with_deadline(
19292                WorkspaceId::new("primary").unwrap(),
19293                agent("claude"),
19294                mode,
19295                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19296                None,
19297                environment_profile,
19298                None,
19299                None,
19300                None,
19301                Some(ProviderRuntimePolicy::raw_pty()),
19302                SpawnRecordPolicy::ProviderIdentityOnly,
19303                Some(Instant::now() + Duration::from_secs(30)),
19304                required,
19305                harness_mcp,
19306                ApprovalLevel::default(),
19307                None,
19308                None,
19309            )
19310            .await
19311    }
19312
19313    fn assert_no_session_state(shared: &NodeShared) {
19314        assert!(shared.handle.snapshot().sessions.is_empty());
19315        assert!(shared
19316            .session_bindings
19317            .lock()
19318            .unwrap_or_else(|poisoned| poisoned.into_inner())
19319            .is_empty());
19320        assert!(shared
19321            .materializations
19322            .lock()
19323            .unwrap_or_else(|poisoned| poisoned.into_inner())
19324            .is_empty());
19325    }
19326
19327    #[tokio::test]
19328    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19329        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19330        let error = characterization_spawn(
19331            &shared,
19332            SessionMode::Pty,
19333            None,
19334            &[ProviderRuntimeCapability::SemanticReadiness],
19335            None,
19336        )
19337        .await
19338        .unwrap_err();
19339        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19340        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19341        assert_no_session_state(&shared);
19342        drop(shared);
19343        drop(runtime);
19344        std::fs::remove_dir_all(root).unwrap();
19345    }
19346
19347    #[tokio::test]
19348    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19349        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19350        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19351            .await
19352            .unwrap_err();
19353        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19354        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19355        assert_no_session_state(&shared);
19356        drop(shared);
19357        drop(runtime);
19358        std::fs::remove_dir_all(root).unwrap();
19359    }
19360
19361    #[test]
19362    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19363        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19364        let address = SessionAddress {
19365            workspace_id: WorkspaceId::new("primary").unwrap(),
19366            session: SessionKey {
19367                instance_id: AgentInstanceId(77),
19368                generation: SessionGeneration(1),
19369            },
19370        };
19371        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19372        let removed = shared.remove_binding(&address).expect("binding removed");
19373        assert!(removed.environment_profile.is_some());
19374        assert!(shared.remove_binding(&address).is_none());
19375        assert_no_session_state(&shared);
19376        drop(shared);
19377        drop(runtime);
19378        std::fs::remove_dir_all(root).unwrap();
19379    }
19380
19381    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19382        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19383        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19384        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19385            .expect("review current test executable as fixture helper");
19386        let registry = HarnessMcpProxyRegistry::new(helper);
19387        let spec = crate::protocol::SpawnSpec {
19388            target: crate::protocol::SpawnTarget {
19389                node_id: NodeId::new("node-characterization").unwrap(),
19390                workspace_id: WorkspaceId::new("primary").unwrap(),
19391                worktree_id: None,
19392            },
19393            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19394            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19395            overrides: crate::protocol::SpawnOverrides::default(),
19396            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19397            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19398            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19399        };
19400        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19401        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19402        let launch = HarnessMcpLaunchV1 {
19403            server_name: "fixture-harness".to_owned(),
19404            args: vec!["--session-proxy".to_owned()],
19405            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19406            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19407            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19408            trace: None,
19409            scrub_env: Vec::new(),
19410        };
19411        let expires = unix_time_ms().saturating_add(30_000);
19412        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19413        registry
19414            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19415            .unwrap()
19416    }
19417
19418    #[tokio::test]
19419    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19420        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19421        let prepared = characterization_prepared_harness("claude").await;
19422        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19423            .await
19424            .unwrap_err();
19425        // Inline is stopped by runtime admission before the harness check runs.
19426        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19427        let mismatched = characterization_prepared_harness("codex").await;
19428        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19429            .await
19430            .unwrap_err();
19431        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19432        assert_no_session_state(&shared);
19433        drop(shared);
19434        drop(runtime);
19435        std::fs::remove_dir_all(root).unwrap();
19436    }
19437
19438    #[tokio::test]
19439    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19440        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19441        shared.native_launch_profile_control = None;
19442        let prepared = characterization_prepared_harness("claude").await;
19443        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19444            .await
19445            .unwrap_err();
19446        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19447        assert_no_session_state(&shared);
19448        drop(shared);
19449        drop(runtime);
19450        std::fs::remove_dir_all(root).unwrap();
19451    }
19452}
19453
19454#[cfg(all(test, windows))]
19455mod tests {
19456    use super::*;
19457    use gate4agent_types::ProviderActivity;
19458    use crate::session_environment::{
19459        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19460        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19461        NodeSessionPathClass,
19462    };
19463    use gate4agent_catalog::EnvMutation;
19464    use gate4agent_runtime_native::{
19465        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19466        NativeLaunchProfile, NativeLaunchProfileId,
19467    };
19468    use std::ffi::OsString;
19469    use std::sync::Arc;
19470
19471    struct EmptyEnvironmentResolver;
19472
19473    struct FixtureSecretResolver {
19474        deny: Arc<AtomicBool>,
19475    }
19476
19477    impl NodeSecretResolver for FixtureSecretResolver {
19478        fn resolve(
19479            &self,
19480            _reference: &NodeSecretReference,
19481        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19482            if self.deny.load(Ordering::Acquire) {
19483                Err(NodeSecretResolveError::Denied)
19484            } else {
19485                NodeSecretValue::text("fixture-secret")
19486                    .map_err(|_| NodeSecretResolveError::Unavailable)
19487            }
19488        }
19489    }
19490
19491    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19492        fn resolve_child_environment(
19493            &self,
19494        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19495            Ok(vec![EnvMutation {
19496                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19497                value: None,
19498            }])
19499        }
19500    }
19501
19502    fn agent(value: &str) -> AgentId {
19503        AgentId::new(value).unwrap()
19504    }
19505
19506    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19507        let catalog = active_registry().unwrap();
19508        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19509        let workspace = WorkspaceConfig::new(
19510            WorkspaceId::new("primary").unwrap(),
19511            std::env::current_dir().unwrap(),
19512        )
19513        .unwrap()
19514        .with_worktree_service_mode(mode);
19515        NodeShared::new(
19516            handle,
19517            "fixture-token".to_owned(),
19518            NodeId::new("node-terminal-test").unwrap(),
19519            vec![workspace],
19520            vec![agent("claude")],
19521        )
19522    }
19523
19524    fn terminal_test_shared() -> NodeShared {
19525        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19526    }
19527
19528    #[cfg(feature = "fixture")]
19529    #[test]
19530    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19531        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19532        assert_eq!(probe.latest_code(), None);
19533
19534        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19535        assert_eq!(
19536            probe.latest_code(),
19537            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19538        );
19539
19540        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19541        assert_eq!(
19542            probe.latest_code(),
19543            Some(NodeFailureCode::SpawnDeadlineExceeded),
19544        );
19545
19546        probe.record(None);
19547        assert_eq!(probe.latest_code(), None);
19548    }
19549
19550    #[test]
19551    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19552        let source = temporary_workspace_root("launch-inventory-source");
19553        let allocation = temporary_workspace_root("launch-inventory-allocation");
19554        std::fs::create_dir_all(&source).unwrap();
19555        std::fs::create_dir_all(&allocation).unwrap();
19556        let workspace_id = WorkspaceId::new("primary").unwrap();
19557        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19558            .unwrap()
19559            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19560        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19561            workspace = workspace
19562                .with_managed_worktree_profile(
19563                    ManagedWorktreeProfile::new(
19564                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19565                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19566                        &allocation,
19567                        "gate4agent",
19568                        "HEAD",
19569                        ManagedWorktreeRetention::RemoveWhenReleased,
19570                    )
19571                    .unwrap(),
19572                )
19573                .unwrap();
19574        }
19575        let overflow = workspace.clone().with_managed_worktree_profile(
19576            ManagedWorktreeProfile::new(
19577                WorktreeProfileId::new("overflow").unwrap(),
19578                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19579                &allocation,
19580                "gate4agent",
19581                "HEAD",
19582                ManagedWorktreeRetention::Retain,
19583            )
19584            .unwrap(),
19585        );
19586        assert!(matches!(
19587            overflow,
19588            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19589                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19590                ..
19591            }),
19592        ));
19593
19594        let catalog = active_registry().unwrap();
19595        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19596        let shared = NodeShared::new(
19597            handle,
19598            "fixture-token".to_owned(),
19599            NodeId::new("node-launch-inventory").unwrap(),
19600            vec![workspace],
19601            vec![agent("claude")],
19602        );
19603        let snapshot = shared.snapshot();
19604        let launch = snapshot.launch_inventory.as_ref().unwrap();
19605        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19606        assert!(launch.bundles.as_ref().unwrap().is_empty());
19607        let profiles = snapshot.workspaces[0]
19608            .managed_worktree_profiles
19609            .as_ref()
19610            .unwrap();
19611        assert_eq!(
19612            snapshot.workspaces[0].worktree_service_mode,
19613            Some(WorktreeServiceMode::Managed),
19614        );
19615        assert_eq!(
19616            profiles.profiles.len(),
19617            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19618        );
19619        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19620        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19621            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19622        }
19623        let legacy = snapshot_for_wire(
19624            &shared,
19625            true,
19626            true,
19627            false,
19628            false,
19629            true,
19630            false,
19631            true,
19632            false,
19633            true,
19634        );
19635        assert!(legacy.launch_inventory.is_none());
19636        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19637
19638        drop(shared);
19639        std::fs::remove_dir_all(source).unwrap();
19640        std::fs::remove_dir_all(allocation).unwrap();
19641    }
19642
19643    #[test]
19644    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19645        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19646            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19647        }));
19648        let source = gate4agent_types::ProviderSource {
19649            family: AdapterFamily::PtySemantic,
19650            binding: AdapterBinding::new(
19651                gate4agent_types::AdapterId::new("claude").unwrap(),
19652                "fixture/v1",
19653                gate4agent_types::AdapterVerification::SyntheticFixture,
19654            )
19655            .unwrap(),
19656        };
19657        let provider = ProviderSnapshot {
19658            sequence: 17,
19659            session: Some(ProviderSessionIdentity {
19660                key: ProviderSessionKey::SessionId,
19661                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19662                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19663            }),
19664            model: Some("PRIVATE_MODEL".to_owned()),
19665            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19666            completed_turns: 3,
19667            usage: gate4agent_types::TokenUsage {
19668                input_tokens: 10,
19669                output_tokens: 20,
19670                cache_read_tokens: 30,
19671                cache_write_tokens: 40,
19672                reasoning_tokens: 50,
19673                context_window: Some(200_000),
19674            },
19675            lead_activity: ProviderActivity::WaitingForInput,
19676            activity: ProviderActivity::WaitingForInput,
19677            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19678            active_tools: (0..9)
19679                .map(|index| gate4agent_types::ActiveProviderTool {
19680                    id: format!("private-tool-id-{index}"),
19681                    name: if index == 0 {
19682                        format!("  Read\u{0000}{}", "x".repeat(80))
19683                    } else {
19684                        format!("Tool-{index}")
19685                    },
19686                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19687                })
19688                .collect(),
19689            interactions: vec![gate4agent_types::ProviderInteraction {
19690                id: gate4agent_types::ProviderInteractionId(17),
19691                source: source.clone(),
19692                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19693                interaction_kind: ProviderInteractionKind::Approval,
19694                tool_name: " Approve\u{0007}Tool ".to_owned(),
19695                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19696                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19697                resume_lead_activity: Some(ProviderActivity::Working),
19698                status: ProviderInteractionStatus::Pending,
19699            }],
19700            subagents: vec![gate4agent_types::ProviderSubagent {
19701                source: source.clone(),
19702                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19703                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19704                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19705            }],
19706            sources: vec![gate4agent_types::ProviderSourceCursor {
19707                source,
19708                sequence: 17,
19709                gap_count: 2,
19710                stale: true,
19711            }],
19712            last_event: Some(ProviderEvent::Error {
19713                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19714            }),
19715            gap_count: 2,
19716            stale: true,
19717        };
19718        let address = terminal_address(3);
19719        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19720        assert_eq!(entry.address, address);
19721        assert_eq!(entry.progress.provider_sequence, 17);
19722        assert_eq!(entry.progress.active_tool_count, 9);
19723        assert_eq!(
19724            entry.progress.active_tool_labels.len(),
19725            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19726        );
19727        assert_eq!(entry.progress.subagent_count, 1);
19728        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19729        assert!(entry.progress.truncated);
19730        let encoded = serde_json::to_string(&entry).unwrap();
19731        for forbidden in [
19732            "PRIVATE_PROVIDER_SESSION",
19733            "PRIVATE_PROVIDER_PATH",
19734            "PRIVATE_MODEL",
19735            "PRIVATE_TOOL_INVENTORY",
19736            "PRIVATE_CURRENT_PROMPT",
19737            "PRIVATE_TOOL_INPUT_JSON",
19738            "PRIVATE_PROVIDER_REQUEST",
19739            "PRIVATE_INTERACTION_PROMPT",
19740            "PRIVATE_INTERACTION_AGENT",
19741            "PRIVATE_SUBAGENT_ID",
19742            "PRIVATE_SUBAGENT_TYPE",
19743            "PRIVATE_SUBAGENT_DESCRIPTION",
19744            "PRIVATE_PROVIDER_ERROR",
19745            "input_json",
19746            "current_prompt",
19747            "session_id",
19748        ] {
19749            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19750        }
19751
19752        let snapshot = NodeSnapshot {
19753            node_id: NodeId::new("node-progress-test").unwrap(),
19754            enabled_providers: vec![agent("claude")],
19755            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19756            workspaces: Vec::new(),
19757            session_records: Vec::new(),
19758            managed_worktrees: Vec::new(),
19759            launch_inventory: None,
19760            agent_progress: vec![entry],
19761        };
19762        let mut legacy_reply = ResponseEnvelope {
19763            request_id: 1,
19764            result: Ok(NodeResponse::Snapshot {
19765                event_sequence: 0,
19766                controller: None,
19767                snapshot: snapshot.clone(),
19768            }),
19769        };
19770        project_response_without_agent_progress(&mut legacy_reply);
19771        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19772            panic!("agent progress projection changed response kind");
19773        };
19774        assert!(legacy.agent_progress.is_empty());
19775        assert_eq!(snapshot.agent_progress.len(), 1);
19776    }
19777
19778    fn terminal_address(generation: u64) -> SessionAddress {
19779        SessionAddress {
19780            workspace_id: WorkspaceId::new("primary").unwrap(),
19781            session: SessionKey {
19782                instance_id: AgentInstanceId(41),
19783                generation: SessionGeneration(generation),
19784            },
19785        }
19786    }
19787
19788    fn spawn_spec_fixture() -> SpawnSpec {
19789        SpawnSpec {
19790            target: crate::protocol::SpawnTarget {
19791                node_id: NodeId::new("node-terminal-test").unwrap(),
19792                workspace_id: WorkspaceId::new("primary").unwrap(),
19793                worktree_id: None,
19794            },
19795            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19796            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19797                .unwrap(),
19798            overrides: crate::protocol::SpawnOverrides::default(),
19799            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19800            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19801            required_capabilities: SpawnRequiredCapabilities::default(),
19802        }
19803    }
19804
19805    fn environment_profile_test_shared(
19806    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19807        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19808        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19809            "local-claude-r1",
19810        )
19811        .unwrap();
19812        let spawn_profiles = SpawnProfileRegistry::new([
19813            crate::protocol::SpawnProfileDefaults {
19814                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19815                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19816                provider: agent("claude"),
19817                mode: SessionMode::Pty,
19818                terminal_size: gate4agent_types::TerminalSize {
19819                    rows: 24,
19820                    columns: 80,
19821                },
19822                prompt: None,
19823                bundle_id: None,
19824                context_id: None,
19825                environment_profile_id: Some(profile_id.clone()),
19826            },
19827        ])
19828        .unwrap();
19829        let catalog = active_registry().unwrap();
19830        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19831        let control = runtime.native_launch_profile_control();
19832        let node_profile = NodeEnvironmentProfile::new(
19833            profile_id.clone(),
19834            profile_revision.clone(),
19835            agent("claude"),
19836            [NativeLaunchProfile::new(
19837                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19838                agent("claude"),
19839                TransportKind::Pty,
19840                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19841                Arc::new(EmptyEnvironmentResolver),
19842            )
19843            .unwrap()],
19844        )
19845        .unwrap();
19846        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19847        for native_profile in native_profiles {
19848            runtime.upsert_native_launch_profile(native_profile).unwrap();
19849        }
19850        let workspace = WorkspaceConfig::new(
19851            WorkspaceId::new("primary").unwrap(),
19852            std::env::current_dir().unwrap(),
19853        )
19854        .unwrap();
19855        let shared = NodeShared::new_with_incarnation(
19856            handle,
19857            "fixture-token".to_owned(),
19858            NodeId::new("node-terminal-test").unwrap(),
19859            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19860            vec![workspace],
19861            vec![agent("claude")],
19862            ProviderRuntimeStatuses::default(),
19863            None,
19864            Vec::new(),
19865            Vec::new(),
19866            spawn_profiles,
19867            Some(control),
19868            None,
19869            None,
19870            None,
19871            Vec::new(),
19872            Vec::new(),
19873            Vec::new(),
19874            Vec::new(),
19875            None,
19876            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19877        );
19878        shared
19879            .environment_profiles
19880            .write()
19881            .unwrap_or_else(|poisoned| poisoned.into_inner())
19882            .insert(binding.id.clone(), binding);
19883        (
19884            shared,
19885            runtime,
19886            ResolvedEnvironmentProfileReceipt {
19887                profile_id,
19888                profile_revision,
19889                network_allowlist: None,
19890                browser_profile_id: None,
19891            },
19892        )
19893    }
19894
19895    fn materialization_test_shared(
19896        deny_secret: bool,
19897    ) -> (
19898        NodeShared,
19899        NativeRuntime,
19900        ResolvedEnvironmentProfileReceipt,
19901        PathBuf,
19902        Arc<AtomicBool>,
19903    ) {
19904        let workspace = WorkspaceConfig::new(
19905            WorkspaceId::new("primary").unwrap(),
19906            std::env::current_dir().unwrap(),
19907        )
19908        .unwrap();
19909        materialization_test_shared_with_workspace(deny_secret, workspace)
19910    }
19911
19912    fn materialization_test_shared_with_workspace(
19913        deny_secret: bool,
19914        workspace: WorkspaceConfig,
19915    ) -> (
19916        NodeShared,
19917        NativeRuntime,
19918        ResolvedEnvironmentProfileReceipt,
19919        PathBuf,
19920        Arc<AtomicBool>,
19921    ) {
19922        let root = temporary_workspace_root(if deny_secret {
19923            "materialization-denied"
19924        } else {
19925            "materialization-ready"
19926        });
19927        std::fs::create_dir_all(&root).unwrap();
19928        let materialization_root = root.join("session-environments");
19929        let deny = Arc::new(AtomicBool::new(deny_secret));
19930        let resolver = Arc::new(FixtureSecretResolver {
19931            deny: Arc::clone(&deny),
19932        });
19933        let materializer = SessionEnvironmentMaterializer::new(
19934            materialization_root,
19935            resolver,
19936        )
19937        .unwrap();
19938        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19939        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19940            "materialized-claude-r1",
19941        )
19942        .unwrap();
19943        let materialization = NodeSessionMaterializationProfile::new(
19944            vec![NodeSessionEnvironmentMutation::SetSecret {
19945                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19946                reference: NodeSecretReference::new("fixture-token").unwrap(),
19947            }],
19948            vec![NodeSessionPathBinding::new(
19949                "GATE4AGENT_PROVIDER_HOME",
19950                NodeSessionPathClass::ProviderHome,
19951            )
19952            .unwrap()],
19953            vec![NodeSessionFile::secret(
19954                NodeSessionPathClass::Config,
19955                "auth/token",
19956                NodeSecretReference::new("fixture-token").unwrap(),
19957            )
19958            .unwrap()],
19959        )
19960        .unwrap();
19961        let catalog = active_registry().unwrap();
19962        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19963        let control = runtime.native_launch_profile_control();
19964        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19965            profile_id.clone(),
19966            profile_revision.clone(),
19967            agent("claude"),
19968            [NativeLaunchProfile::new(
19969                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19970                agent("claude"),
19971                TransportKind::Pty,
19972                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19973                Arc::new(EmptyEnvironmentResolver),
19974            )
19975            .unwrap()],
19976            Some(materialization),
19977        )
19978        .unwrap();
19979        let (binding, native_profiles, materialization) = node_profile.into_parts();
19980        for native_profile in native_profiles {
19981            runtime.upsert_native_launch_profile(native_profile).unwrap();
19982        }
19983        let shared = NodeShared::new_with_incarnation(
19984            handle,
19985            "fixture-token".to_owned(),
19986            NodeId::new("node-materialization-test").unwrap(),
19987            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19988            vec![workspace],
19989            vec![agent("claude")],
19990            ProviderRuntimeStatuses::default(),
19991            None,
19992            Vec::new(),
19993            Vec::new(),
19994            SpawnProfileRegistry::default(),
19995            Some(control),
19996            None,
19997            Some(materializer),
19998            None,
19999            Vec::new(),
20000            Vec::new(),
20001            Vec::new(),
20002            Vec::new(),
20003            None,
20004            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20005        );
20006        shared
20007            .environment_profiles
20008            .write()
20009            .unwrap_or_else(|poisoned| poisoned.into_inner())
20010            .insert(binding.id.clone(), binding);
20011        shared
20012            .environment_materialization_profiles
20013            .write()
20014            .unwrap_or_else(|poisoned| poisoned.into_inner())
20015            .insert(profile_id.clone(), materialization.unwrap());
20016        (
20017            shared,
20018            runtime,
20019            ResolvedEnvironmentProfileReceipt {
20020                profile_id,
20021                profile_revision,
20022                network_allowlist: None,
20023                browser_profile_id: None,
20024            },
20025            root,
20026            deny,
20027        )
20028    }
20029
20030    #[cfg(feature = "fixture")]
20031    #[derive(Clone, Copy)]
20032    enum CodexHomeProfile {
20033        Missing,
20034        WrongClass,
20035        Exact,
20036    }
20037
20038    #[cfg(feature = "fixture")]
20039    fn codex_bundle_test_shared(
20040        home_profile: CodexHomeProfile,
20041    ) -> (
20042        NodeShared,
20043        NativeRuntime,
20044        ResolvedEnvironmentProfileReceipt,
20045        NodeBundle,
20046        PathBuf,
20047    ) {
20048        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20049        const CLAUDE_MANIFEST: &[u8] =
20050            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20051        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20052        const DIGEST: &str =
20053            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20054
20055        let (shared, mut runtime, _claude_receipt, root, _deny) =
20056            materialization_test_shared(false);
20057        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20058            CodexHomeProfile::Missing => "codex-home-missing",
20059            CodexHomeProfile::WrongClass => "codex-home-wrong",
20060            CodexHomeProfile::Exact => "codex-home-exact",
20061        })
20062        .unwrap();
20063        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20064            "codex-home-r1",
20065        )
20066        .unwrap();
20067        let materialization = match home_profile {
20068            CodexHomeProfile::Missing => None,
20069            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20070                Some(NodeSessionMaterializationProfile::new(
20071                    Vec::new(),
20072                    vec![NodeSessionPathBinding::new(
20073                        "CODEX_HOME",
20074                        if matches!(home_profile, CodexHomeProfile::Exact) {
20075                            NodeSessionPathClass::ProviderHome
20076                        } else {
20077                            NodeSessionPathClass::Config
20078                        },
20079                    )
20080                    .unwrap()],
20081                    Vec::new(),
20082                )
20083                .unwrap())
20084            }
20085        };
20086        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20087            profile_id.clone(),
20088            profile_revision.clone(),
20089            agent("codex"),
20090            [NativeLaunchProfile::new(
20091                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20092                agent("codex"),
20093                TransportKind::Pty,
20094                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20095                Arc::new(EmptyEnvironmentResolver),
20096            )
20097            .unwrap()],
20098            materialization,
20099        )
20100        .unwrap();
20101        let (binding, native_profiles, materialization) = node_profile.into_parts();
20102        for native_profile in native_profiles {
20103            runtime.upsert_native_launch_profile(native_profile).unwrap();
20104        }
20105        shared
20106            .environment_profiles
20107            .write()
20108            .unwrap_or_else(|poisoned| poisoned.into_inner())
20109            .insert(binding.id.clone(), binding);
20110        if let Some(materialization) = materialization {
20111            shared
20112                .environment_materialization_profiles
20113                .write()
20114                .unwrap_or_else(|poisoned| poisoned.into_inner())
20115                .insert(profile_id.clone(), materialization);
20116        }
20117
20118        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20119        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20120        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20121        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20122        std::fs::write(
20123            bundle_source.join(".claude-plugin/plugin.json"),
20124            CLAUDE_MANIFEST,
20125        )
20126        .unwrap();
20127        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20128        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20129        let bundle = NodeBundle::new(
20130            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20131            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20132            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20133            &bundle_source,
20134        )
20135        .unwrap();
20136        *shared
20137            .bundle_catalog
20138            .write()
20139            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20140            BundleCatalog::new([bundle.clone()]).unwrap();
20141        (
20142            shared,
20143            runtime,
20144            ResolvedEnvironmentProfileReceipt {
20145                profile_id,
20146                profile_revision,
20147                network_allowlist: None,
20148                browser_profile_id: None,
20149            },
20150            bundle,
20151            root,
20152        )
20153    }
20154
20155    #[cfg(feature = "fixture")]
20156    #[test]
20157    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20158        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20159            let (shared, runtime, receipt, bundle, root) =
20160                codex_bundle_test_shared(home_profile);
20161            let error = shared
20162                .bundle_layout(
20163                    &agent("codex"),
20164                    SessionMode::Pty,
20165                    Some(&receipt),
20166                    &bundle,
20167                )
20168                .unwrap_err();
20169            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20170            assert!(shared
20171                .materializations
20172                .lock()
20173                .unwrap_or_else(|poisoned| poisoned.into_inner())
20174                .is_empty());
20175            let materialization_root = root.join("session-environments");
20176            assert!(!std::fs::read_dir(&materialization_root)
20177                .unwrap()
20178                .filter_map(Result::ok)
20179                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20180            drop(shared);
20181            drop(runtime);
20182            let _ = std::fs::remove_dir_all(root);
20183        }
20184    }
20185
20186    #[cfg(feature = "fixture")]
20187    #[test]
20188    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20189        let (shared, runtime, receipt, bundle, root) =
20190            codex_bundle_test_shared(CodexHomeProfile::Exact);
20191        let address = SessionAddress {
20192            workspace_id: WorkspaceId::new("primary").unwrap(),
20193            session: SessionKey {
20194                instance_id: AgentInstanceId(310),
20195                generation: SessionGeneration(1),
20196            },
20197        };
20198        let (overlay, guard) = shared
20199            .prepare_session_materialization(
20200                &address,
20201                &agent("codex"),
20202                SessionMode::Pty,
20203                Some(&receipt),
20204                Some(&bundle.receipt()),
20205                None,
20206                None,
20207            )
20208            .unwrap()
20209            .unwrap();
20210        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20211        let ownership = shared
20212            .materializations
20213            .lock()
20214            .unwrap_or_else(|poisoned| poisoned.into_inner())
20215            .get(guard.id().unwrap())
20216            .unwrap()
20217            .clone();
20218        let (profile, _, _) = shared
20219            .materialization_profile(
20220                &agent("codex"),
20221                SessionMode::Pty,
20222                Some(&receipt),
20223                Some(&bundle.receipt()),
20224                None,
20225            )
20226            .unwrap()
20227            .unwrap();
20228        let environment = shared
20229            .session_environment_materializer
20230            .as_ref()
20231            .unwrap()
20232            .resolve_environment(&ownership, &profile)
20233            .unwrap();
20234        assert!(environment.iter().any(|mutation| {
20235            mutation.key == OsString::from("CODEX_HOME")
20236                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20237        }));
20238        assert_eq!(
20239            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20240                .unwrap(),
20241            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20242        );
20243        assert!(std::fs::read_dir(ownership.bundle_root())
20244            .unwrap()
20245            .next()
20246            .is_none());
20247        drop(guard);
20248        drop(shared);
20249        drop(runtime);
20250        let _ = std::fs::remove_dir_all(root);
20251    }
20252
20253    #[cfg(feature = "fixture")]
20254    #[test]
20255    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20256        let (shared, runtime, receipt, bundle, root) =
20257            codex_bundle_test_shared(CodexHomeProfile::Exact);
20258        let bundle_receipt = bundle.receipt();
20259        let address = SessionAddress {
20260            workspace_id: WorkspaceId::new("primary").unwrap(),
20261            session: SessionKey {
20262                instance_id: AgentInstanceId(311),
20263                generation: SessionGeneration(1),
20264            },
20265        };
20266        let (_overlay, guard) = shared
20267            .prepare_session_materialization(
20268                &address,
20269                &agent("codex"),
20270                SessionMode::Pty,
20271                Some(&receipt),
20272                Some(&bundle_receipt),
20273                None,
20274                None,
20275            )
20276            .unwrap()
20277            .unwrap();
20278        let materialization_id = guard.id().unwrap().clone();
20279        let skill_path = shared
20280            .materializations
20281            .lock()
20282            .unwrap_or_else(|poisoned| poisoned.into_inner())
20283            .get(&materialization_id)
20284            .unwrap()
20285            .provider_home()
20286            .join("skills/review-code/SKILL.md");
20287        let record_id = shared
20288            .bind_spawn_session_with_materialization(
20289                &address,
20290                agent("codex"),
20291                SessionMode::Pty,
20292                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20293                SpawnRecordPolicy::ProviderIdentityOnly,
20294                Some(receipt.clone()),
20295                Some(bundle_receipt.clone()),
20296                None,
20297                Some(materialization_id.clone()),
20298            )
20299            .unwrap()
20300            .unwrap();
20301        guard.retain();
20302        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20303
20304        let error = match shared.resolve_record_materialization(
20305                &record_id,
20306                &agent("codex"),
20307                SessionMode::Pty,
20308                Some(&receipt),
20309                Some(&bundle_receipt),
20310                None,
20311            ) {
20312                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20313                Err(error) => error,
20314            };
20315        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20316        assert_eq!(
20317            shared
20318                .materializations
20319                .lock()
20320                .unwrap_or_else(|poisoned| poisoned.into_inner())
20321                .get(&materialization_id)
20322                .unwrap()
20323                .state(),
20324            MaterializationState::RecoveryRequired,
20325        );
20326        drop(shared);
20327        drop(runtime);
20328        let _ = std::fs::remove_dir_all(root);
20329    }
20330
20331    fn bind_record_owned_materialization_fixture(
20332        shared: &NodeShared,
20333        receipt: &ResolvedEnvironmentProfileReceipt,
20334        instance_id: u64,
20335    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20336        let address = SessionAddress {
20337            workspace_id: WorkspaceId::new("primary").unwrap(),
20338            session: SessionKey {
20339                instance_id: AgentInstanceId(instance_id),
20340                generation: SessionGeneration(1),
20341            },
20342        };
20343        let (overlay, materialization_guard) = shared
20344            .prepare_session_materialization(
20345                &address,
20346                &agent("claude"),
20347                SessionMode::Pty,
20348                Some(receipt),
20349                None,
20350                None,
20351                None,
20352            )
20353            .unwrap()
20354            .unwrap();
20355        let materialization_id = materialization_guard.id().unwrap().clone();
20356        let materialization_root = shared
20357            .materializations
20358            .lock()
20359            .unwrap_or_else(|poisoned| poisoned.into_inner())
20360            .get(&materialization_id)
20361            .unwrap()
20362            .root()
20363            .to_path_buf();
20364        let selection = shared
20365            .select_environment_profile(
20366                address.session.instance_id,
20367                &agent("claude"),
20368                SessionMode::Pty,
20369                Some(receipt),
20370            )
20371            .unwrap()
20372            .unwrap();
20373        assert!(shared
20374            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20375            .unwrap()
20376            .is_none());
20377        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20378        let record_id = shared
20379            .bind_spawn_session_with_materialization(
20380                &address,
20381                agent("claude"),
20382                SessionMode::Pty,
20383                policy,
20384                SpawnRecordPolicy::ProviderIdentityOnly,
20385                Some(receipt.clone()),
20386                None,
20387                None,
20388                Some(materialization_id.clone()),
20389            )
20390            .unwrap()
20391            .unwrap();
20392        selection.retain();
20393        materialization_guard.retain();
20394        (address, record_id, materialization_id, materialization_root)
20395    }
20396
20397    #[test]
20398    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20399        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20400        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20401            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20402        }));
20403        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20404        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20405        let mut spec = spawn_spec_fixture();
20406        spec.expected_profile_revision =
20407            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20408        assert!(matches!(
20409            &spec.overrides.environment_profile_id,
20410            crate::protocol::SpawnOverride::Inherit,
20411        ));
20412        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20413            .requires_child_environment_profile_capability());
20414        assert!(request_requires_child_environment_profile(
20415            &shared,
20416            &NodeRequest::SpawnSpec { spec: spec.clone() },
20417        ));
20418
20419        let address = terminal_address(1);
20420        shared.bind_session_with_policy(
20421            &address,
20422            ProviderRuntimePolicy::raw_pty(),
20423            Some(environment_profile.clone()),
20424        );
20425        assert!(request_requires_child_environment_profile(
20426            &shared,
20427            &NodeRequest::Input {
20428                session: address.clone(),
20429                text: "x".to_owned(),
20430            },
20431        ));
20432
20433        let mut record = record("claude", "profile-record");
20434        record.active_session = Some(address.clone());
20435        record.environment_profile = Some(environment_profile.clone());
20436        let record_id = record.record_id.clone();
20437        shared.insert_record(record.clone()).unwrap();
20438        assert!(request_requires_child_environment_profile(
20439            &shared,
20440            &NodeRequest::ResumeSessionRecord {
20441                record_id,
20442                terminal_size: gate4agent_types::TerminalSize {
20443                    rows: 24,
20444                    columns: 80,
20445                },
20446                initial_prompt: None,
20447            },
20448        ));
20449
20450        let snapshot = snapshot_for_wire(
20451            &shared, false, true, true, true, false, false, false, false, true,
20452        );
20453        assert_eq!(snapshot.session_records.len(), 1);
20454        assert!(snapshot.session_records[0].environment_profile.is_none());
20455        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20456            sequence: 1,
20457            event: NodeEvent::SessionRecordUpserted { record },
20458        });
20459        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20460            panic!("record projection changed the event kind");
20461        };
20462        assert!(record.environment_profile.is_none());
20463
20464        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20465        let mut reply = ResponseEnvelope {
20466            request_id: 1,
20467            result: Ok(NodeResponse::SpawnSpecAccepted {
20468                receipt: resolved.receipt_with_environment(
20469                    shared.incarnation_id,
20470                    address,
20471                    Some(environment_profile),
20472                ),
20473            }),
20474        };
20475        project_response_without_child_environment_profile(&mut reply);
20476        assert_eq!(
20477            reply.result.unwrap_err().code,
20478            NodeFailureCode::UnsupportedCapability,
20479        );
20480    }
20481
20482    #[test]
20483    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20484        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20485        let snapshot = shared.snapshot();
20486        let profiles = snapshot
20487            .launch_inventory
20488            .as_ref()
20489            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20490            .unwrap();
20491        assert_eq!(profiles.len(), 1);
20492        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20493        assert!(snapshot.requires_child_environment_profile_capability());
20494
20495        let mut legacy = snapshot.clone();
20496        clear_snapshot_child_environment_profiles(&mut legacy);
20497        assert!(legacy
20498            .launch_inventory
20499            .as_ref()
20500            .unwrap()
20501            .spawn_profiles
20502            .as_ref()
20503            .unwrap()[0]
20504            .environment_profile
20505            .is_none());
20506        assert!(!legacy.requires_child_environment_profile_capability());
20507
20508        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20509        let bindings = shared
20510            .environment_profiles
20511            .read()
20512            .unwrap_or_else(|poisoned| poisoned.into_inner())
20513            .clone();
20514        let mut provider_mismatch = bindings.clone();
20515        provider_mismatch
20516            .values_mut()
20517            .next()
20518            .unwrap()
20519            .provider = agent("codex");
20520        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20521        let mut mode_mismatch = profile;
20522        mode_mismatch.mode = SessionMode::Inline;
20523        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20524
20525        shared
20526            .environment_profiles
20527            .write()
20528            .unwrap_or_else(|poisoned| poisoned.into_inner())
20529            .clear();
20530        assert!(shared
20531            .snapshot()
20532            .launch_inventory
20533            .unwrap()
20534            .spawn_profiles
20535            .unwrap()
20536            .is_empty());
20537    }
20538
20539    #[test]
20540    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20541        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20542            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20543        }));
20544        let shared = terminal_test_shared();
20545        let bundle = ResolvedBundleReceipt {
20546            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20547            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20548            digest: crate::protocol::SpawnBundleDigest::new(format!(
20549                "sha256:{}",
20550                "0".repeat(64),
20551            ))
20552            .unwrap(),
20553        };
20554        let mut bundled = record("claude", "bundle-record");
20555        bundled.bundle = Some(bundle.clone());
20556        let record_id = bundled.record_id.clone();
20557        shared.insert_record(bundled.clone()).unwrap();
20558        assert!(request_requires_session_bundle(
20559            &shared,
20560            &NodeRequest::ForgetSessionRecord {
20561                record_id: record_id.clone(),
20562            },
20563        ));
20564
20565        let snapshot = snapshot_for_wire(
20566            &shared, false, true, true, true, true, false, false, false, true,
20567        );
20568        assert!(snapshot.session_records[0].bundle.is_none());
20569        let launch = snapshot.launch_inventory.as_ref().unwrap();
20570        assert!(launch.spawn_profiles.is_some());
20571        assert!(launch.bundles.is_none());
20572        let event = project_event_without_session_bundle(NodeEventEnvelope {
20573            sequence: 1,
20574            event: NodeEvent::SessionRecordUpserted { record: bundled },
20575        });
20576        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20577            panic!("record projection changed the event kind");
20578        };
20579        assert!(record.bundle.is_none());
20580
20581        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20582        let mut reply = ResponseEnvelope {
20583            request_id: 1,
20584            result: Ok(NodeResponse::SpawnSpecAccepted {
20585                receipt: resolved.receipt_with_materialization(
20586                    shared.incarnation_id,
20587                    terminal_address(17),
20588                    None,
20589                    Some(bundle),
20590                    None,
20591                ),
20592            }),
20593        };
20594        project_response_without_session_bundle(&mut reply);
20595        assert_eq!(
20596            reply.result.unwrap_err().code,
20597            NodeFailureCode::UnsupportedCapability,
20598        );
20599    }
20600
20601    #[test]
20602    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20603        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20604            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20605        }));
20606        let context_id = SpawnContextId::new("context-review").unwrap();
20607        let context = ResolvedContextPackReceipt {
20608            id: context_id.clone(),
20609            digest: crate::protocol::SpawnContextDigest::new(format!(
20610                "sha256:{}",
20611                "c".repeat(64),
20612            ))
20613            .unwrap(),
20614            lineage: ContextPackLineageReceipt {
20615                source_node_id: NodeId::new("node-source").unwrap(),
20616                source_session: terminal_address(1),
20617                source_provider: agent("kimi"),
20618            },
20619            source_message_count: 2,
20620            retained_message_count: 2,
20621            byte_len: 256,
20622            truncated: false,
20623        };
20624        assert!(context.is_valid());
20625        let mut bound_record = record("codex", "context-record");
20626        bound_record.context_id = Some(context_id);
20627        bound_record.context = Some(context.clone());
20628        let session = gate4agent_types::SessionSnapshot {
20629            instance_id: AgentInstanceId(91),
20630            agent_id: agent("kimi"),
20631            transport: TransportKind::Pty,
20632            generation: SessionGeneration(3),
20633            status: SessionStatus::Running,
20634            pending_operation: None,
20635            pending_input: None,
20636            process_id: Some(9001),
20637            terminal_size: None,
20638            terminal_frame: None,
20639            terminal_stale: None,
20640            session_options: None,
20641            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20642            history: gate4agent_types::HistorySnapshot {
20643                pending: None,
20644                candidates: vec![HistoryCandidateSummary {
20645                    id: "candidate-opaque".to_owned(),
20646                    session_id_hint: "session-hint".to_owned(),
20647                    modified_at_unix_ms: Some(1),
20648                }],
20649                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20650                loaded: Some(HistorySessionRecord {
20651                    session_id: "private-vendor-session".to_owned(),
20652                    title: Some("private title".to_owned()),
20653                    cwd: Some(r"C:\private\history-root".to_owned()),
20654                    model: Some("private-model".to_owned()),
20655                    message_count: 2,
20656                    completed_turn_count: None,
20657                    total_tokens: 19,
20658                    messages: vec![
20659                        gate4agent_types::HistoryMessageRecord {
20660                            role: gate4agent_types::HistoryMessageRole::User,
20661                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20662                        },
20663                        gate4agent_types::HistoryMessageRecord {
20664                            role: gate4agent_types::HistoryMessageRole::Assistant,
20665                            text: "private answer".to_owned(),
20666                        },
20667                    ],
20668                }),
20669                last_error: None,
20670            },
20671            resume: gate4agent_types::ResumeSnapshot::default(),
20672            foreground: gate4agent_types::ForegroundSnapshot::default(),
20673            provider: gate4agent_types::ProviderSnapshot::default(),
20674            screen_state: gate4agent_types::PtyScreenState::default(),
20675        };
20676        let original = NodeSnapshot {
20677            node_id: NodeId::new("node-terminal-test").unwrap(),
20678            enabled_providers: vec![agent("kimi"), agent("codex")],
20679            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20680            workspaces: vec![WorkspaceSnapshot {
20681                workspace_id: WorkspaceId::new("primary").unwrap(),
20682                canonical_root: opaque_windows_path(
20683                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20684                ),
20685                sessions: vec![session],
20686                worktree_service_mode: None,
20687                managed_worktree_profiles: None,
20688            }],
20689            session_records: vec![bound_record],
20690            managed_worktrees: Vec::new(),
20691            launch_inventory: None,
20692            agent_progress: Vec::new(),
20693        };
20694
20695        let mut negotiated = original.clone();
20696        project_snapshot_history_for_wire(&mut negotiated, true);
20697        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20698        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20699        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20700        assert!(!serde_json::to_string(&negotiated)
20701            .unwrap()
20702            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20703
20704        let mut legacy = original;
20705        project_snapshot_history_for_wire(&mut legacy, false);
20706        clear_snapshot_context_packs(&mut legacy);
20707        assert_eq!(
20708            legacy.workspaces[0].sessions[0].history,
20709            gate4agent_types::HistorySnapshot::default(),
20710        );
20711        assert!(legacy.session_records[0].context_id.is_none());
20712        assert!(legacy.session_records[0].context.is_none());
20713        let encoded = serde_json::to_string(&legacy).unwrap();
20714        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20715        assert!(!encoded.contains("context-review"));
20716
20717        let mut reply = ResponseEnvelope {
20718            request_id: 7,
20719            result: Ok(NodeResponse::ContextPackExported { context }),
20720        };
20721        project_response_without_context_pack(&mut reply);
20722        assert_eq!(
20723            reply.result.unwrap_err().code,
20724            NodeFailureCode::UnsupportedCapability,
20725        );
20726    }
20727
20728    #[test]
20729    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20730        let shared = terminal_test_shared();
20731        let history = HistorySessionRecord {
20732            session_id: "fixture-history".to_owned(),
20733            title: None,
20734            cwd: None,
20735            model: None,
20736            message_count: 1,
20737            completed_turn_count: None,
20738            total_tokens: 0,
20739            messages: vec![gate4agent_types::HistoryMessageRecord {
20740                role: gate4agent_types::HistoryMessageRole::User,
20741                text: "bounded handoff".to_owned(),
20742            }],
20743        };
20744        let pack = NodeContextPack::export(
20745            ContextPackLineageReceipt {
20746                source_node_id: shared.node_id.clone(),
20747                source_session: terminal_address(1),
20748                source_provider: agent("claude"),
20749            },
20750            &history,
20751        )
20752        .unwrap();
20753        let context = shared
20754            .context_catalog
20755            .write()
20756            .unwrap_or_else(|poisoned| poisoned.into_inner())
20757            .insert(pack)
20758            .unwrap();
20759        let mut retained = record("codex", "context-retained-record");
20760        retained.context_id = Some(context.id.clone());
20761        retained.context = Some(context.clone());
20762        let record_id = retained.record_id.clone();
20763        shared.insert_record(retained).unwrap();
20764        assert_eq!(
20765            shared.forget_context_pack(&context.id).unwrap_err().code,
20766            NodeFailureCode::ContextPackBusy,
20767        );
20768        assert!(shared
20769            .context_catalog
20770            .read()
20771            .unwrap_or_else(|poisoned| poisoned.into_inner())
20772            .get(&context.id)
20773            .is_some());
20774        shared
20775            .session_records
20776            .lock()
20777            .unwrap_or_else(|poisoned| poisoned.into_inner())
20778            .records
20779            .remove(&record_id);
20780
20781        let mut spec = spawn_spec_fixture();
20782        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20783            value: context.id.clone(),
20784        };
20785        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20786        let receipt = resolved.receipt_with_materialization(
20787            shared.incarnation_id,
20788            terminal_address(2),
20789            None,
20790            None,
20791            Some(context.clone()),
20792        );
20793        assert!(receipt.context_binding_is_valid());
20794        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20795
20796        shared.forget_context_pack(&context.id).unwrap();
20797        assert!(shared
20798            .context_catalog
20799            .read()
20800            .unwrap_or_else(|poisoned| poisoned.into_inner())
20801            .get(&context.id)
20802            .is_none());
20803        assert!(shared
20804            .replay_spawn_spec(&spec, Instant::now())
20805            .unwrap()
20806            .is_none());
20807    }
20808
20809    #[test]
20810    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20811        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20812        let address = terminal_address(1);
20813        let selection = shared
20814            .select_environment_profile(
20815                address.session.instance_id,
20816                &agent("claude"),
20817                SessionMode::Pty,
20818                Some(&environment_profile),
20819            )
20820            .unwrap()
20821            .unwrap();
20822        shared.bind_session_with_policy(
20823            &address,
20824            ProviderRuntimePolicy::raw_pty(),
20825            Some(environment_profile.clone()),
20826        );
20827        selection.retain();
20828        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20829        assert!(matches!(
20830            runtime.remove_native_launch_profile(&native_id),
20831            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20832        ));
20833        assert!(shared.remove_binding(&address).is_some());
20834        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20835
20836        let mismatched = ResolvedEnvironmentProfileReceipt {
20837            profile_id: environment_profile.profile_id,
20838            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20839                "local-claude-r2",
20840            )
20841            .unwrap(),
20842            network_allowlist: None,
20843            browser_profile_id: None,
20844        };
20845        let error = match shared.select_environment_profile(
20846                AgentInstanceId(42),
20847                &agent("claude"),
20848                SessionMode::Pty,
20849                Some(&mismatched),
20850            ) {
20851            Ok(_) => panic!("mismatched environment profile revision was selected"),
20852            Err(error) => error,
20853        };
20854        assert_eq!(
20855            error.code,
20856            NodeFailureCode::EnvironmentProfileBindingMismatch,
20857        );
20858        assert!(!shared
20859            .session_bindings
20860            .lock()
20861            .unwrap_or_else(|poisoned| poisoned.into_inner())
20862            .contains_key(&AgentInstanceId(42)));
20863    }
20864
20865    #[test]
20866    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20867        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20868        let address = terminal_address(1);
20869        let selection = shared
20870            .select_environment_profile(
20871                address.session.instance_id,
20872                &agent("claude"),
20873                SessionMode::Pty,
20874                Some(&environment_profile),
20875            )
20876            .unwrap()
20877            .unwrap();
20878        let overlay = NativeInstanceLaunchOverlay::new(
20879            agent("claude"),
20880            TransportKind::Pty,
20881            vec![EnvMutation {
20882                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20883                value: Some(OsString::from("fixture-secret")),
20884            }],
20885            vec![
20886                OsString::from("--plugin-dir"),
20887                std::env::current_dir().unwrap().into_os_string(),
20888            ],
20889        )
20890        .unwrap();
20891        let instance_overlay = shared
20892            .install_prepared_launch_overlay(
20893                address.session.instance_id,
20894                PreparedNativeLaunchOverlay::Instance(overlay),
20895            )
20896            .unwrap()
20897            .unwrap();
20898        let bundle = ResolvedBundleReceipt {
20899            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20900            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20901            digest: crate::protocol::SpawnBundleDigest::new(format!(
20902                "sha256:{}",
20903                "0".repeat(64),
20904            ))
20905            .unwrap(),
20906        };
20907        shared.bind_session_with_materialization(
20908            &address,
20909            ProviderRuntimePolicy::raw_pty(),
20910            Some(environment_profile),
20911            Some(bundle),
20912            None,
20913            None,
20914        );
20915        selection.retain();
20916        instance_overlay.retain();
20917
20918        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20919        assert!(matches!(
20920            runtime.remove_native_launch_profile(&native_id),
20921            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20922        ));
20923        assert!(shared.remove_binding(&address).is_some());
20924        assert!(!shared
20925            .native_launch_profile_control
20926            .as_ref()
20927            .unwrap()
20928            .clear_native_instance_launch_overlay(address.session.instance_id));
20929        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20930    }
20931
20932    #[test]
20933    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20934        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20935        let address = SessionAddress {
20936            workspace_id: WorkspaceId::new("primary").unwrap(),
20937            session: SessionKey {
20938                instance_id: AgentInstanceId(201),
20939                generation: SessionGeneration(1),
20940            },
20941        };
20942        let error = match shared.prepare_session_materialization(
20943            &address,
20944            &agent("claude"),
20945            SessionMode::Pty,
20946            Some(&receipt),
20947            None,
20948            None,
20949            None,
20950        ) {
20951            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20952            Err(error) => error,
20953        };
20954        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20955        assert!(shared.handle.snapshot().sessions.is_empty());
20956        assert!(shared
20957            .session_bindings
20958            .lock()
20959            .unwrap_or_else(|poisoned| poisoned.into_inner())
20960            .is_empty());
20961        assert!(shared
20962            .materializations
20963            .lock()
20964            .unwrap_or_else(|poisoned| poisoned.into_inner())
20965            .is_empty());
20966        drop(shared);
20967        drop(runtime);
20968        std::fs::remove_dir_all(root).unwrap();
20969    }
20970
20971    #[test]
20972    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20973        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20974        let address = SessionAddress {
20975            workspace_id: WorkspaceId::new("primary").unwrap(),
20976            session: SessionKey {
20977                instance_id: AgentInstanceId(202),
20978                generation: SessionGeneration(1),
20979            },
20980        };
20981        let (overlay, materialization_guard) = shared
20982            .prepare_session_materialization(
20983                &address,
20984                &agent("claude"),
20985                SessionMode::Pty,
20986                Some(&receipt),
20987                None,
20988                None,
20989                None,
20990            )
20991            .unwrap()
20992            .unwrap();
20993        let materialization_id = materialization_guard.id().unwrap().clone();
20994        let materialization_root = shared
20995            .materializations
20996            .lock()
20997            .unwrap_or_else(|poisoned| poisoned.into_inner())
20998            .get(&materialization_id)
20999            .unwrap()
21000            .root()
21001            .to_path_buf();
21002        let selection = shared
21003            .select_environment_profile(
21004                address.session.instance_id,
21005                &agent("claude"),
21006                SessionMode::Pty,
21007                Some(&receipt),
21008            )
21009            .unwrap()
21010            .unwrap();
21011        assert!(shared
21012            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21013            .unwrap()
21014            .is_none());
21015        shared.bind_session_with_materialization(
21016            &address,
21017            ProviderRuntimePolicy::raw_pty(),
21018            Some(receipt),
21019            None,
21020            None,
21021            Some(materialization_id),
21022        );
21023        selection.retain();
21024        materialization_guard.retain();
21025
21026        assert!(materialization_root.is_dir());
21027        assert!(shared
21028            .session_bindings
21029            .lock()
21030            .unwrap_or_else(|poisoned| poisoned.into_inner())
21031            .contains_key(&address.session.instance_id));
21032
21033        let removed = shared.remove_binding(&address).unwrap();
21034        shared
21035            .cleanup_session_owned_materialization(&address, &removed)
21036            .unwrap();
21037        assert!(!materialization_root.exists());
21038        assert!(shared
21039            .materializations
21040            .lock()
21041            .unwrap_or_else(|poisoned| poisoned.into_inner())
21042            .is_empty());
21043        drop(shared);
21044        drop(runtime);
21045        std::fs::remove_dir_all(root).unwrap();
21046    }
21047
21048    #[tokio::test]
21049    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21050        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21051        let address = SessionAddress {
21052            workspace_id: WorkspaceId::new("primary").unwrap(),
21053            session: SessionKey {
21054                instance_id: AgentInstanceId(203),
21055                generation: SessionGeneration(1),
21056            },
21057        };
21058        let (overlay, materialization_guard) = shared
21059            .prepare_session_materialization(
21060                &address,
21061                &agent("claude"),
21062                SessionMode::Pty,
21063                Some(&receipt),
21064                None,
21065                None,
21066                None,
21067            )
21068            .unwrap()
21069            .unwrap();
21070        let materialization_id = materialization_guard.id().unwrap().clone();
21071        let materialization_root = shared
21072            .materializations
21073            .lock()
21074            .unwrap_or_else(|poisoned| poisoned.into_inner())
21075            .get(&materialization_id)
21076            .unwrap()
21077            .root()
21078            .to_path_buf();
21079        let selection = shared
21080            .select_environment_profile(
21081                address.session.instance_id,
21082                &agent("claude"),
21083                SessionMode::Pty,
21084                Some(&receipt),
21085            )
21086            .unwrap()
21087            .unwrap();
21088        assert!(shared
21089            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21090            .unwrap()
21091            .is_none());
21092        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21093        let record_id = shared
21094            .bind_spawn_session_with_materialization(
21095                &address,
21096                agent("claude"),
21097                SessionMode::Pty,
21098                policy,
21099                SpawnRecordPolicy::ProviderIdentityOnly,
21100                Some(receipt.clone()),
21101                None,
21102                None,
21103                Some(materialization_id.clone()),
21104            )
21105            .unwrap()
21106            .unwrap();
21107        selection.retain();
21108        materialization_guard.retain();
21109        assert_eq!(
21110            shared
21111                .materializations
21112                .lock()
21113                .unwrap_or_else(|poisoned| poisoned.into_inner())
21114                .get(&materialization_id)
21115                .unwrap()
21116                .owner(),
21117            &MaterializationOwner::Record {
21118                record_id: record_id.clone(),
21119            },
21120        );
21121
21122        let removed = shared.remove_binding(&address).unwrap();
21123        shared
21124            .cleanup_session_owned_materialization(&address, &removed)
21125            .unwrap();
21126        assert!(materialization_root.is_dir());
21127        assert!(shared
21128            .resolve_record_materialization(
21129                &record_id,
21130                &agent("claude"),
21131                SessionMode::Pty,
21132                Some(&receipt),
21133                None,
21134                None,
21135            )
21136            .unwrap()
21137            .is_some());
21138        {
21139            let mut records = shared
21140                .session_records
21141                .lock()
21142                .unwrap_or_else(|poisoned| poisoned.into_inner());
21143            let record = records.records.get_mut(&record_id).unwrap();
21144            record.active_session = None;
21145            record.state = ManagedSessionState::Unavailable;
21146        }
21147        shared.forget_session_record(&record_id).await.unwrap();
21148        assert!(!materialization_root.exists());
21149        assert!(shared
21150            .materializations
21151            .lock()
21152            .unwrap_or_else(|poisoned| poisoned.into_inner())
21153            .is_empty());
21154        drop(shared);
21155        drop(runtime);
21156        std::fs::remove_dir_all(root).unwrap();
21157    }
21158
21159    #[test]
21160    fn record_materialization_revalidation_failure_persists_recovery_required() {
21161        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21162        let state_path = root.join("node-state-v6.json");
21163        shared.state_path = Some(state_path.clone());
21164        let (_address, _record_id, materialization_id, materialization_root) =
21165            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21166        std::fs::write(
21167            materialization_root.join(".gate4agent-materialization-owner"),
21168            b"tampered",
21169        )
21170        .unwrap();
21171
21172        let error = match shared.resolve_record_materialization(
21173            &_record_id,
21174            &agent("claude"),
21175            SessionMode::Pty,
21176            Some(&receipt),
21177            None,
21178            None,
21179        ) {
21180            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21181            Err(error) => error,
21182        };
21183        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21184        assert_eq!(
21185            shared
21186                .materializations
21187                .lock()
21188                .unwrap_or_else(|poisoned| poisoned.into_inner())
21189                .get(&materialization_id)
21190                .unwrap()
21191                .state(),
21192            MaterializationState::RecoveryRequired,
21193        );
21194        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21195        assert_eq!(loaded.materializations.len(), 1);
21196        assert_eq!(
21197            loaded.materializations[0].state(),
21198            MaterializationState::RecoveryRequired,
21199        );
21200        drop(shared);
21201        drop(runtime);
21202        std::fs::remove_dir_all(root).unwrap();
21203    }
21204
21205    #[test]
21206    fn transient_record_materialization_resolution_failure_remains_ready() {
21207        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21208        let (_address, record_id, materialization_id, _materialization_root) =
21209            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21210        deny.store(true, Ordering::Release);
21211
21212        let error = match shared.resolve_record_materialization(
21213            &record_id,
21214            &agent("claude"),
21215            SessionMode::Pty,
21216            Some(&receipt),
21217            None,
21218            None,
21219        ) {
21220            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21221            Err(error) => error,
21222        };
21223        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21224        assert_eq!(
21225            shared
21226                .materializations
21227                .lock()
21228                .unwrap_or_else(|poisoned| poisoned.into_inner())
21229                .get(&materialization_id)
21230                .unwrap()
21231                .state(),
21232            MaterializationState::Ready,
21233        );
21234        drop(shared);
21235        drop(runtime);
21236        std::fs::remove_dir_all(root).unwrap();
21237    }
21238
21239    #[test]
21240    fn startup_missing_record_materialization_is_not_resumable() {
21241        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21242        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21243        shared
21244            .insert_record(ManagedSessionRecord {
21245                record_id: record_id.clone(),
21246                display_name: "missing materialization".to_owned(),
21247                provider: agent("claude"),
21248                mode: SessionMode::Pty,
21249                state: ManagedSessionState::Dormant,
21250                workspace_id: WorkspaceId::new("primary").unwrap(),
21251                canonical_root: opaque_windows_path(
21252                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21253                ),
21254                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21255                    key: gate4agent_types::ProviderSessionKey::SessionId,
21256                    id: "provider-session-missing-materialization".to_owned(),
21257                    transcript_path: None,
21258                }),
21259                active_session: None,
21260                environment_profile: Some(receipt),
21261                bundle: None,
21262                context_id: None,
21263                context: None,
21264                exported_context: None,
21265                task_binding: None,
21266                created_at_unix_ms: 1,
21267                updated_at_unix_ms: 1,
21268                last_error: None,
21269            })
21270            .unwrap();
21271
21272        shared.reconcile_materializations();
21273        let record = shared.record(&record_id).unwrap();
21274        assert_eq!(record.state, ManagedSessionState::Unavailable);
21275        assert!(record.provider_session.is_none());
21276        assert_eq!(
21277            record.last_error.as_deref(),
21278            Some("environment-profile-unavailable"),
21279        );
21280        drop(shared);
21281        drop(runtime);
21282        std::fs::remove_dir_all(root).unwrap();
21283    }
21284
21285    #[tokio::test]
21286    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21287        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21288        let lease = install_managed_lease(&shared);
21289        let address = SessionAddress {
21290            workspace_id: WorkspaceId::new("primary").unwrap(),
21291            session: SessionKey {
21292                instance_id: AgentInstanceId(204),
21293                generation: SessionGeneration(1),
21294            },
21295        };
21296        let (overlay, materialization_guard) = shared
21297            .prepare_session_materialization(
21298                &address,
21299                &agent("claude"),
21300                SessionMode::Pty,
21301                Some(&receipt),
21302                None,
21303                None,
21304                Some(lease.lease_id.clone()),
21305            )
21306            .unwrap()
21307            .unwrap();
21308        let materialization_id = materialization_guard.id().unwrap().clone();
21309        let materialization_root = shared
21310            .materializations
21311            .lock()
21312            .unwrap_or_else(|poisoned| poisoned.into_inner())
21313            .get(&materialization_id)
21314            .unwrap()
21315            .root()
21316            .to_path_buf();
21317        let selection = shared
21318            .select_environment_profile(
21319                address.session.instance_id,
21320                &agent("claude"),
21321                SessionMode::Pty,
21322                Some(&receipt),
21323            )
21324            .unwrap()
21325            .unwrap();
21326        assert!(shared
21327            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21328            .unwrap()
21329            .is_none());
21330        shared.bind_session_with_materialization(
21331            &address,
21332            ProviderRuntimePolicy::raw_pty(),
21333            Some(receipt),
21334            None,
21335            None,
21336            Some(materialization_id),
21337        );
21338        selection.retain();
21339        materialization_guard.retain();
21340
21341        let blocked = shared
21342            .cleanup_managed_worktree(&lease.lease_id, false)
21343            .await
21344            .unwrap_err();
21345        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21346        assert!(materialization_root.is_dir());
21347        let removed = shared.remove_binding(&address).unwrap();
21348        shared
21349            .cleanup_session_owned_materialization(&address, &removed)
21350            .unwrap();
21351        assert!(!materialization_root.exists());
21352        drop(shared);
21353        drop(runtime);
21354        std::fs::remove_dir_all(root).unwrap();
21355    }
21356
21357    #[tokio::test]
21358    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21359        let git_root = temporary_workspace_root("raw-managed-inventory");
21360        let source = git_root.join("source");
21361        let allocation = git_root.join("allocation");
21362        std::fs::create_dir_all(&source).unwrap();
21363        std::fs::create_dir_all(&allocation).unwrap();
21364        run_test_git(&source, &["init"]);
21365        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21366        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21367        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21368        run_test_git(&source, &["add", "seed.txt"]);
21369        run_test_git(&source, &["commit", "-m", "seed"]);
21370        let profile = ManagedWorktreeProfile::new(
21371            WorktreeProfileId::new("default").unwrap(),
21372            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21373            &allocation,
21374            "gate4agent",
21375            "HEAD",
21376            ManagedWorktreeRetention::RemoveWhenReleased,
21377        )
21378        .unwrap();
21379        let workspace = WorkspaceConfig::new(
21380            WorkspaceId::new("primary").unwrap(),
21381            &source,
21382        )
21383        .unwrap()
21384        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21385        .with_managed_worktree_profile(profile.clone())
21386        .unwrap();
21387        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21388            materialization_test_shared_with_workspace(false, workspace);
21389        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21390        let source_text = source.to_string_lossy().into_owned();
21391        let target_text = target.to_string_lossy().into_owned();
21392        let created = create_git_worktree(
21393            &source_text,
21394            &target_text,
21395            "gate4agent/mw-raw-inventory",
21396            Some("HEAD"),
21397        )
21398        .await
21399        .unwrap();
21400        let lease = ManagedWorktreeLeaseRecord {
21401            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21402            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21403            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21404            profile_id: profile.profile_id().clone(),
21405            profile_revision: profile.revision().clone(),
21406            target_root: created.path.clone(),
21407            branch: "gate4agent/mw-raw-inventory".to_owned(),
21408            base_commit: created.head.clone(),
21409            expected_head: Some(created.head.clone()),
21410            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21411            state: ManagedWorktreeLeaseState::Ready,
21412            session_holders: Vec::new(),
21413            record_holders: Vec::new(),
21414            cleanup_failure: None,
21415            created_at_unix_ms: 1,
21416            updated_at_unix_ms: 1,
21417        };
21418        shared
21419            .workspaces
21420            .write()
21421            .unwrap_or_else(|poisoned| poisoned.into_inner())
21422            .insert(lease.workspace_id.clone(), created.path.clone());
21423        *shared
21424            .managed_worktrees
21425            .lock()
21426            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21427            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21428        let address = SessionAddress {
21429            workspace_id: lease.workspace_id.clone(),
21430            session: SessionKey {
21431                instance_id: AgentInstanceId(205),
21432                generation: SessionGeneration(1),
21433            },
21434        };
21435        let (overlay, materialization_guard) = shared
21436            .prepare_session_materialization(
21437                &address,
21438                &agent("claude"),
21439                SessionMode::Pty,
21440                Some(&receipt),
21441                None,
21442                None,
21443                Some(lease.lease_id.clone()),
21444            )
21445            .unwrap()
21446            .unwrap();
21447        let materialization_id = materialization_guard.id().unwrap().clone();
21448        let materialization_root = shared
21449            .materializations
21450            .lock()
21451            .unwrap_or_else(|poisoned| poisoned.into_inner())
21452            .get(&materialization_id)
21453            .unwrap()
21454            .root()
21455            .to_path_buf();
21456        let selection = shared
21457            .select_environment_profile(
21458                address.session.instance_id,
21459                &agent("claude"),
21460                SessionMode::Pty,
21461                Some(&receipt),
21462            )
21463            .unwrap()
21464            .unwrap();
21465        assert!(shared
21466            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21467            .unwrap()
21468            .is_none());
21469        let record_id = shared
21470            .bind_spawn_session_with_materialization(
21471                &address,
21472                agent("claude"),
21473                SessionMode::Pty,
21474                ProviderRuntimePolicy::raw_pty(),
21475                SpawnRecordPolicy::Always,
21476                Some(receipt.clone()),
21477                None,
21478                None,
21479                Some(materialization_id.clone()),
21480            )
21481            .unwrap()
21482            .unwrap();
21483        selection.retain();
21484        materialization_guard.retain();
21485        let record = shared.record(&record_id).unwrap();
21486        assert_eq!(record.state, ManagedSessionState::Live);
21487        assert!(record.provider_session.is_none());
21488        assert!(record.environment_profile.is_none());
21489        assert!(record.bundle.is_none());
21490        assert!(record.context_id.is_none());
21491        assert!(record.context.is_none());
21492        assert_eq!(record.active_session.as_ref(), Some(&address));
21493        assert_eq!(
21494            shared
21495                .materializations
21496                .lock()
21497                .unwrap_or_else(|poisoned| poisoned.into_inner())
21498                .get(&materialization_id)
21499                .unwrap()
21500                .owner(),
21501            &MaterializationOwner::Session {
21502                incarnation_id: shared.incarnation_id,
21503                instance_id: address.session.instance_id,
21504                generation: address.session.generation,
21505            },
21506        );
21507        {
21508            let mut bindings = shared
21509                .session_bindings
21510                .lock()
21511                .unwrap_or_else(|poisoned| poisoned.into_inner());
21512            bindings
21513                .get_mut(&address.session.instance_id)
21514                .unwrap()
21515                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21516        }
21517        let lease_snapshot = shared
21518            .managed_worktrees
21519            .lock()
21520            .unwrap_or_else(|poisoned| poisoned.into_inner())
21521            .bind_session(
21522                &lease.lease_id,
21523                ManagedWorktreeSessionHolder {
21524                    incarnation_id: shared.incarnation_id,
21525                    instance_id: address.session.instance_id,
21526                    generation: address.session.generation,
21527                },
21528                None,
21529                unix_time_ms(),
21530            )
21531            .unwrap();
21532        assert_eq!(lease_snapshot.active_session_count, 1);
21533        assert_eq!(lease_snapshot.managed_record_count, 0);
21534        shared.persist_state().unwrap();
21535        assert!(shared
21536            .managed_worktrees
21537            .lock()
21538            .unwrap_or_else(|poisoned| poisoned.into_inner())
21539            .get(&lease.lease_id)
21540            .unwrap()
21541            .record_holders
21542            .is_empty());
21543        shared
21544            .handle
21545            .dispatch(shared.prepare_command(ControlCommand::Register {
21546                instance_id: address.session.instance_id,
21547                agent_id: agent("claude"),
21548                transport: TransportKind::Pty,
21549            }))
21550            .unwrap();
21551        runtime.tick().await;
21552        shared.reconcile_managed_record(
21553            &record_id,
21554            &address,
21555            &ControlEvent {
21556                sequence: 1,
21557                command_id: None,
21558                instance_id: address.session.instance_id,
21559                generation: address.session.generation,
21560                event: ControlEventKind::Removed,
21561            },
21562            false,
21563            false,
21564        );
21565        let remove = shared.remove_session(&address);
21566        let drive_runtime = async {
21567            runtime.tick().await;
21568        };
21569        let (removed, ()) = tokio::join!(remove, drive_runtime);
21570        removed.unwrap();
21571
21572        assert!(!materialization_root.exists());
21573        assert!(!Path::new(&created.path).exists());
21574        assert_eq!(
21575            shared
21576                .managed_worktrees
21577                .lock()
21578                .unwrap_or_else(|poisoned| poisoned.into_inner())
21579                .get(&lease.lease_id)
21580                .unwrap()
21581                .state,
21582            ManagedWorktreeLeaseState::Removed,
21583        );
21584        let renamed = shared
21585            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21586            .unwrap();
21587        assert_eq!(renamed.record_id, record_id);
21588        assert_eq!(renamed.display_name, "raw managed inventory");
21589        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21590        assert!(renamed.active_session.is_none());
21591
21592        drop(shared);
21593        drop(runtime);
21594        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21595        std::fs::remove_dir_all(git_root).unwrap();
21596    }
21597
21598    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21599        let root = std::env::current_dir().unwrap()
21600            .join("managed-test-target")
21601            .to_string_lossy()
21602            .into_owned();
21603        let lease = ManagedWorktreeLeaseRecord {
21604            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21605            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21606            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21607            profile_id: WorktreeProfileId::new("default").unwrap(),
21608            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21609            target_root: root.clone(),
21610            branch: "gate4agent/mw-test".to_owned(),
21611            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21612            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21613            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21614            state: ManagedWorktreeLeaseState::Ready,
21615            session_holders: Vec::new(),
21616            record_holders: Vec::new(),
21617            cleanup_failure: None,
21618            created_at_unix_ms: 1,
21619            updated_at_unix_ms: 1,
21620        };
21621        shared.workspaces.write()
21622            .unwrap_or_else(|poisoned| poisoned.into_inner())
21623            .insert(lease.workspace_id.clone(), root);
21624        *shared.managed_worktrees.lock()
21625            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21626            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21627        lease
21628    }
21629
21630    #[cfg(feature = "fixture")]
21631    #[test]
21632    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21633        let mut shared = terminal_test_shared();
21634        shared.fixture_semantic_hook_policy = true;
21635        let lease = install_managed_lease(&shared);
21636        let address = SessionAddress {
21637            workspace_id: lease.workspace_id.clone(),
21638            session: SessionKey {
21639                instance_id: AgentInstanceId(206),
21640                generation: SessionGeneration(1),
21641            },
21642        };
21643        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21644        let effective_runtime_policy =
21645            shared.effective_spawn_runtime_policy(pre_admission_policy);
21646        assert_ne!(effective_runtime_policy, pre_admission_policy);
21647        assert!(effective_runtime_policy.provider_session_identity);
21648
21649        let record_id = shared
21650            .bind_spawn_session(
21651                &address,
21652                agent("claude"),
21653                SessionMode::Pty,
21654                effective_runtime_policy,
21655                SpawnRecordPolicy::Always,
21656                None,
21657            )
21658            .unwrap()
21659            .unwrap();
21660        assert_eq!(
21661            shared.record(&record_id).unwrap().state,
21662            ManagedSessionState::IdentityPending,
21663        );
21664        let (record_holder, raw_inventory_record_id) = {
21665            let mut bindings = shared
21666                .session_bindings
21667                .lock()
21668                .unwrap_or_else(|poisoned| poisoned.into_inner());
21669            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21670            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21671            managed_spawn_record_ownership(binding, effective_runtime_policy)
21672        };
21673        assert_eq!(record_holder.as_ref(), Some(&record_id));
21674        assert!(raw_inventory_record_id.is_none());
21675        let snapshot = shared
21676            .managed_worktrees
21677            .lock()
21678            .unwrap_or_else(|poisoned| poisoned.into_inner())
21679            .bind_session(
21680                &lease.lease_id,
21681                ManagedWorktreeSessionHolder {
21682                    incarnation_id: shared.incarnation_id,
21683                    instance_id: address.session.instance_id,
21684                    generation: address.session.generation,
21685                },
21686                record_holder,
21687                unix_time_ms(),
21688            )
21689            .unwrap();
21690        assert_eq!(snapshot.managed_record_count, 1);
21691        assert_eq!(
21692            shared
21693                .managed_worktrees
21694                .lock()
21695                .unwrap_or_else(|poisoned| poisoned.into_inner())
21696                .get(&lease.lease_id)
21697                .unwrap()
21698                .record_holders,
21699            vec![record_id.clone()],
21700        );
21701
21702        let identity = ProviderSessionIdentity {
21703            key: ProviderSessionKey::SessionId,
21704            id: "monitoring-fixture-managed-session".to_owned(),
21705            transcript_path: None,
21706        };
21707        shared.publish_control(ControlEvent {
21708            sequence: 1,
21709            command_id: None,
21710            instance_id: address.session.instance_id,
21711            generation: address.session.generation,
21712            event: ControlEventKind::ProviderEvent {
21713                sequence: 1,
21714                source: gate4agent_types::ProviderSource {
21715                    family: AdapterFamily::Hook,
21716                    binding: AdapterBinding::new(
21717                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21718                        "fixture/v1",
21719                        gate4agent_types::AdapterVerification::SyntheticFixture,
21720                    )
21721                    .unwrap(),
21722                },
21723                source_sequence: 1,
21724                event: ProviderEvent::SessionIdentityObserved {
21725                    identity: identity.clone(),
21726                },
21727            },
21728        });
21729
21730        let live = shared.record(&record_id).unwrap();
21731        assert_eq!(live.state, ManagedSessionState::Live);
21732        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21733        assert_eq!(live.active_session.as_ref(), Some(&address));
21734        assert_eq!(
21735            shared
21736                .managed_worktrees
21737                .lock()
21738                .unwrap_or_else(|poisoned| poisoned.into_inner())
21739                .get(&lease.lease_id)
21740                .unwrap()
21741                .record_holders,
21742            vec![record_id],
21743        );
21744    }
21745
21746    fn managed_v2_test_shared(
21747        allocation_root: &Path,
21748        state_path: Option<PathBuf>,
21749        profile_revision: &str,
21750    ) -> NodeShared {
21751        let profile = ManagedWorktreeProfile::new(
21752            WorktreeProfileId::new("default").unwrap(),
21753            WorktreeProfileRevision::new(profile_revision).unwrap(),
21754            allocation_root,
21755            "gate4agent",
21756            "HEAD",
21757            ManagedWorktreeRetention::RemoveWhenReleased,
21758        )
21759        .unwrap();
21760        let workspace = WorkspaceConfig::new(
21761            WorkspaceId::new("primary").unwrap(),
21762            std::env::current_dir().unwrap(),
21763        )
21764        .unwrap()
21765        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21766        .with_managed_worktree_profile(profile)
21767        .unwrap();
21768        let catalog = active_registry().unwrap();
21769        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21770        let mut shared = NodeShared::new(
21771            handle,
21772            "fixture-token".to_owned(),
21773            NodeId::new("node-terminal-test").unwrap(),
21774            vec![workspace],
21775            vec![agent("claude")],
21776        );
21777        shared.state_path = state_path;
21778        shared
21779    }
21780
21781    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21782        let mut spawn_spec = spawn_spec_fixture();
21783        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21784        ManagedWorktreeSpawnRequestV2 {
21785            spawn_spec,
21786            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21787            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21788        }
21789    }
21790
21791    #[test]
21792    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21793        let shared = terminal_test_shared();
21794        let mut lease = install_managed_lease(&shared);
21795        let session = SessionAddress {
21796            workspace_id: lease.workspace_id.clone(),
21797            session: SessionKey {
21798                instance_id: AgentInstanceId(73),
21799                generation: SessionGeneration(4),
21800            },
21801        };
21802        lease.state = ManagedWorktreeLeaseState::InUse;
21803        lease.session_holders.push(ManagedWorktreeSessionHolder {
21804            incarnation_id: shared.incarnation_id,
21805            instance_id: session.session.instance_id,
21806            generation: session.session.generation,
21807        });
21808        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21809        assert!(resolved.target.worktree_id.is_none());
21810
21811        let receipt = managed_spawn_receipt(
21812            &resolved,
21813            shared.incarnation_id,
21814            session.clone(),
21815            lease.snapshot(),
21816            None,
21817            None,
21818            None,
21819        );
21820        assert!(resolved.target.worktree_id.is_none());
21821        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21822        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21823        assert_eq!(receipt.spawn.session, session);
21824        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21825        let encoded = serde_json::to_vec(&receipt).unwrap();
21826        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21827        assert_eq!(decoded, receipt);
21828    }
21829
21830    #[tokio::test]
21831    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21832        let root = temporary_workspace_root("managed-v2-revision");
21833        let allocation = root.join("allocation");
21834        std::fs::create_dir_all(&allocation).unwrap();
21835        let shared = managed_v2_test_shared(
21836            &allocation,
21837            Some(root.join("node-state.json")),
21838            "v1",
21839        );
21840        let mut request = managed_v2_request("managed-v2-revision");
21841        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21842
21843        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21844
21845        assert_eq!(
21846            failure.code,
21847            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21848        );
21849        assert!(shared
21850            .managed_worktrees
21851            .lock()
21852            .unwrap_or_else(|poisoned| poisoned.into_inner())
21853            .records()
21854            .is_empty());
21855        assert!(shared
21856            .managed_spawn_replays
21857            .lock()
21858            .unwrap_or_else(|poisoned| poisoned.into_inner())
21859            .is_empty());
21860        std::fs::remove_dir_all(root).unwrap();
21861    }
21862
21863    #[test]
21864    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21865        let root = temporary_workspace_root("managed-v2-concurrent");
21866        let allocation = root.join("allocation");
21867        std::fs::create_dir_all(&allocation).unwrap();
21868        let shared = Arc::new(managed_v2_test_shared(
21869            &allocation,
21870            Some(root.join("node-state.json")),
21871            "v1",
21872        ));
21873        let request = managed_v2_request("managed-v2-concurrent");
21874        let barrier = Arc::new(std::sync::Barrier::new(3));
21875        let mut joins = Vec::new();
21876        for _ in 0..2 {
21877            let shared = Arc::clone(&shared);
21878            let request = request.clone();
21879            let barrier = Arc::clone(&barrier);
21880            joins.push(std::thread::spawn(move || {
21881                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21882                barrier.wait();
21883                shared.acquire_durable_managed_spawn_v2(&request, digest)
21884            }));
21885        }
21886        barrier.wait();
21887        let results = joins
21888            .into_iter()
21889            .map(|join| join.join().unwrap())
21890            .collect::<Vec<_>>();
21891        assert_eq!(
21892            results
21893                .iter()
21894                .filter(|result| {
21895                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21896                })
21897                .count(),
21898            1,
21899        );
21900        assert_eq!(
21901            results
21902                .iter()
21903                .filter(|result| {
21904                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21905                })
21906                .count(),
21907            1,
21908        );
21909        assert_eq!(
21910            shared
21911                .managed_spawn_replays
21912                .lock()
21913                .unwrap_or_else(|poisoned| poisoned.into_inner())
21914                .len(),
21915            1,
21916        );
21917        assert!(shared
21918            .managed_worktrees
21919            .lock()
21920            .unwrap_or_else(|poisoned| poisoned.into_inner())
21921            .records()
21922            .is_empty());
21923        drop(shared);
21924        std::fs::remove_dir_all(root).unwrap();
21925    }
21926
21927    #[tokio::test]
21928    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21929        let root = temporary_workspace_root("managed-v2-linked-pending");
21930        let allocation = root.join("allocation");
21931        std::fs::create_dir_all(&allocation).unwrap();
21932        let state_path = root.join("node-state.json");
21933        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21934        let lease = install_managed_lease(&shared);
21935        let request = managed_v2_request("managed-v2-linked-pending");
21936        assert!(matches!(
21937            shared
21938                .acquire_durable_managed_spawn_v2(
21939                    &request,
21940                    managed_spawn_request_digest_v2(&request).unwrap(),
21941                )
21942                .unwrap(),
21943            DurableManagedSpawnReplayDecision::Reserved,
21944        ));
21945        {
21946            let _transaction = shared
21947                .state_transaction
21948                .lock()
21949                .unwrap_or_else(|poisoned| poisoned.into_inner());
21950            shared
21951                .link_durable_managed_spawn_v2_lease_locked(
21952                    &request.spawn_spec.idempotency_key,
21953                    &lease.lease_id,
21954                )
21955                .unwrap();
21956            shared.persist_state_locked().unwrap();
21957        }
21958        drop(shared);
21959
21960        let mut loaded = session_registry::load(
21961            Some(&state_path),
21962            &NodeId::new("node-terminal-test").unwrap(),
21963        )
21964        .unwrap();
21965        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21966        *reopened
21967            .managed_worktrees
21968            .lock()
21969            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21970            ManagedWorktreeRegistry::from_records(
21971                std::mem::take(&mut loaded.managed_worktrees),
21972                std::mem::take(&mut loaded.managed_worktree_tombstones),
21973            )
21974            .unwrap();
21975        *reopened
21976            .managed_spawn_replays
21977            .lock()
21978            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21979            .managed_spawn_replays
21980            .into_iter()
21981            .map(|record| (record.idempotency_key.clone(), record))
21982            .collect();
21983        let lease_count = reopened
21984            .managed_worktrees
21985            .lock()
21986            .unwrap_or_else(|poisoned| poisoned.into_inner())
21987            .records()
21988            .len();
21989
21990        let failure = reopened
21991            .spawn_managed_worktree_v2(request)
21992            .await
21993            .unwrap_err();
21994
21995        assert_eq!(
21996            failure.code,
21997            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21998        );
21999        assert_eq!(
22000            reopened
22001                .managed_worktrees
22002                .lock()
22003                .unwrap_or_else(|poisoned| poisoned.into_inner())
22004                .records()
22005                .len(),
22006            lease_count,
22007        );
22008        std::fs::remove_dir_all(root).unwrap();
22009    }
22010
22011    #[tokio::test]
22012    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22013        let root = temporary_workspace_root("managed-v2-reopen");
22014        let allocation = root.join("allocation");
22015        std::fs::create_dir_all(&allocation).unwrap();
22016        let state_path = root.join("node-state.json");
22017        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22018        let lease = install_managed_lease(&shared);
22019        let request = managed_v2_request("managed-v2-reopen");
22020        let session = SessionAddress {
22021            workspace_id: lease.workspace_id.clone(),
22022            session: SessionKey {
22023                instance_id: AgentInstanceId(91),
22024                generation: SessionGeneration(1),
22025            },
22026        };
22027        let snapshot = {
22028            let mut registry = shared
22029                .managed_worktrees
22030                .lock()
22031                .unwrap_or_else(|poisoned| poisoned.into_inner());
22032            let current = registry.get_mut(&lease.lease_id).unwrap();
22033            current.state = ManagedWorktreeLeaseState::InUse;
22034            current.session_holders.push(ManagedWorktreeSessionHolder {
22035                incarnation_id: shared.incarnation_id,
22036                instance_id: session.session.instance_id,
22037                generation: session.session.generation,
22038            });
22039            current.snapshot()
22040        };
22041        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22042        let receipt = managed_spawn_receipt(
22043            &resolved,
22044            shared.incarnation_id,
22045            session,
22046            snapshot,
22047            None,
22048            None,
22049            None,
22050        );
22051        assert!(matches!(
22052            shared
22053                .acquire_durable_managed_spawn_v2(
22054                    &request,
22055                    managed_spawn_request_digest_v2(&request).unwrap(),
22056                )
22057                .unwrap(),
22058            DurableManagedSpawnReplayDecision::Reserved,
22059        ));
22060        {
22061            let _transaction = shared
22062                .state_transaction
22063                .lock()
22064                .unwrap_or_else(|poisoned| poisoned.into_inner());
22065            shared
22066                .link_durable_managed_spawn_v2_lease_locked(
22067                    &request.spawn_spec.idempotency_key,
22068                    &lease.lease_id,
22069                )
22070                .unwrap();
22071            shared.persist_state_locked().unwrap();
22072        }
22073        shared
22074            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22075            .unwrap();
22076        let legacy_same_key = ManagedWorktreeSpawnRequest {
22077            spawn_spec: request.spawn_spec.clone(),
22078            worktree_profile_id: request.worktree_profile_id.clone(),
22079        };
22080        shared.remember_managed_spawn_attempt(
22081            Some(&request.spawn_spec.idempotency_key),
22082            legacy_same_key.clone(),
22083            Ok(receipt.clone()),
22084            Instant::now(),
22085        );
22086        assert!(shared
22087            .replay_managed_spawn(&legacy_same_key, Instant::now())
22088            .unwrap()
22089            .is_none());
22090        drop(shared);
22091
22092        let mut loaded = session_registry::load(
22093            Some(&state_path),
22094            &NodeId::new("node-terminal-test").unwrap(),
22095        )
22096        .unwrap();
22097        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22098        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22099        *reopened
22100            .managed_worktrees
22101            .lock()
22102            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22103            ManagedWorktreeRegistry::from_records(
22104                std::mem::take(&mut loaded.managed_worktrees),
22105                std::mem::take(&mut loaded.managed_worktree_tombstones),
22106            )
22107            .unwrap();
22108        *reopened
22109            .managed_spawn_replays
22110            .lock()
22111            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22112            .managed_spawn_replays
22113            .into_iter()
22114            .map(|record| (record.idempotency_key.clone(), record))
22115            .collect();
22116        let lease_count_before = reopened
22117            .managed_worktrees
22118            .lock()
22119            .unwrap_or_else(|poisoned| poisoned.into_inner())
22120            .records()
22121            .len();
22122
22123        assert_eq!(
22124            reopened
22125                .spawn_managed_worktree_v2(request.clone())
22126                .await
22127                .unwrap(),
22128            receipt,
22129        );
22130        assert_eq!(
22131            reopened
22132                .managed_worktrees
22133                .lock()
22134                .unwrap_or_else(|poisoned| poisoned.into_inner())
22135                .records()
22136                .len(),
22137            lease_count_before,
22138        );
22139        let mut changed = request;
22140        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22141        let failure = reopened
22142            .spawn_managed_worktree_v2(changed)
22143            .await
22144            .unwrap_err();
22145        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22146        assert_eq!(
22147            reopened
22148                .managed_worktrees
22149                .lock()
22150                .unwrap_or_else(|poisoned| poisoned.into_inner())
22151                .records()
22152                .len(),
22153            lease_count_before,
22154        );
22155        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22156        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22157        let failure = reopened
22158            .spawn_managed_worktree_v2(new_key)
22159            .await
22160            .unwrap_err();
22161        assert_eq!(
22162            failure.code,
22163            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22164        );
22165        assert_eq!(
22166            reopened
22167                .managed_worktrees
22168                .lock()
22169                .unwrap_or_else(|poisoned| poisoned.into_inner())
22170                .records()
22171                .len(),
22172            lease_count_before,
22173        );
22174        assert_eq!(
22175            reopened
22176                .managed_spawn_replays
22177                .lock()
22178                .unwrap_or_else(|poisoned| poisoned.into_inner())
22179                .len(),
22180            1,
22181        );
22182        std::fs::remove_dir_all(root).unwrap();
22183    }
22184
22185    #[test]
22186    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22187        let shared = terminal_test_shared();
22188        let request = ManagedWorktreeSpawnRequest {
22189            spawn_spec: spawn_spec_fixture(),
22190            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22191        };
22192        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22193        shared.remember_managed_spawn(
22194            request.clone(),
22195            Err(expected.clone()),
22196            Instant::now(),
22197        );
22198
22199        assert_eq!(
22200            shared
22201                .replay_managed_spawn(&request, Instant::now())
22202                .unwrap()
22203                .unwrap(),
22204            Err(expected),
22205        );
22206        assert!(shared
22207            .managed_spawn_replays
22208            .lock()
22209            .unwrap_or_else(|poisoned| poisoned.into_inner())
22210            .is_empty());
22211    }
22212
22213    #[tokio::test]
22214    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22215        let shared = terminal_test_shared();
22216        let lease = install_managed_lease(&shared);
22217        let raw = shared.spawn_session(
22218            lease.workspace_id.clone(),
22219            agent("claude"),
22220            SessionMode::Pty,
22221            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22222            None,
22223        ).await.unwrap_err();
22224        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22225        let mut spec = spawn_spec_fixture();
22226        spec.target.workspace_id = lease.workspace_id.clone();
22227        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22228        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22229        let mut selected = spawn_spec_fixture();
22230        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22231        selected.target.worktree_id = Some(lease.workspace_id);
22232        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22233        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22234        assert!(shared.handle.snapshot().sessions.is_empty());
22235    }
22236
22237    #[test]
22238    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22239        let shared = terminal_test_shared();
22240        let lease = install_managed_lease(&shared);
22241        let snapshot = snapshot_for_wire(
22242            &shared, true, true, true, false, true, true, false, false, true,
22243        );
22244        assert!(snapshot.managed_worktrees.is_empty());
22245        assert!(snapshot.workspaces.iter().all(|workspace| {
22246            workspace.managed_worktree_profiles.is_none()
22247        }));
22248        let mut reply = ResponseEnvelope {
22249            request_id: 1,
22250            result: Ok(NodeResponse::Resync {
22251                event_sequence: 1,
22252                oldest_available_sequence: 1,
22253                snapshot: shared.snapshot(),
22254                events: vec![NodeEventEnvelope {
22255                    sequence: 1,
22256                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22257                }],
22258            }),
22259        };
22260        project_response_without_managed_worktrees(&mut reply);
22261        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22262            panic!("projection changed the response kind");
22263        };
22264        assert!(snapshot.managed_worktrees.is_empty());
22265        assert!(events.is_empty());
22266        let mut git = git_snapshot_for_parser();
22267        git.branch = Some(lease.branch.clone());
22268        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22269            lease_id: lease.lease_id.clone(),
22270            source_workspace_id: lease.source_workspace_id.clone(),
22271            branch: lease.branch.clone(),
22272            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22273            active_session_count: 1,
22274            managed_record_count: 0,
22275        });
22276        let mut inspection_reply = ResponseEnvelope {
22277            request_id: 2,
22278            result: Ok(NodeResponse::WorkspaceInspected {
22279                inspection: WorkspaceInspection {
22280                    workspace_id: lease.workspace_id.clone(),
22281                    entries: Vec::new(),
22282                    tree_truncated: false,
22283                    git,
22284                    truncation: None,
22285                },
22286            }),
22287        };
22288        project_response_without_managed_worktrees(&mut inspection_reply);
22289        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22290            panic!("projection changed the inspection response kind");
22291        };
22292        assert!(inspection.git.managed_worktree.is_none());
22293        let failure = managed_git_worktree_failure(GitWorktreeError {
22294            kind: GitWorktreeErrorKind::Failed,
22295            message: r"git failed at C:\private\managed-target".to_owned(),
22296        });
22297        assert!(!failure.message.contains("private"));
22298        assert!(!failure.message.contains("managed-target"));
22299    }
22300
22301    #[tokio::test]
22302    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22303        let root = temporary_workspace_root("managed-restart");
22304        let source = root.join("source");
22305        let allocation = root.join("allocation");
22306        std::fs::create_dir_all(&source).unwrap();
22307        std::fs::create_dir_all(&allocation).unwrap();
22308        run_test_git(&source, &["init"]);
22309        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22310        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22311        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22312        run_test_git(&source, &["add", "seed.txt"]);
22313        run_test_git(&source, &["commit", "-m", "seed"]);
22314        let profile = ManagedWorktreeProfile::new(
22315            WorktreeProfileId::new("default").unwrap(),
22316            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22317            &allocation,
22318            "gate4agent",
22319            "HEAD",
22320            ManagedWorktreeRetention::Retain,
22321        ).unwrap();
22322        let workspace = WorkspaceConfig::new(
22323            WorkspaceId::new("primary").unwrap(),
22324            &source,
22325        ).unwrap()
22326            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22327            .with_managed_worktree_profile(profile.clone()).unwrap();
22328        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22329        let source_text = workspace.canonical_root().to_owned();
22330        let target_text = target.to_string_lossy().into_owned();
22331        let created = create_git_worktree(
22332            &source_text,
22333            &target_text,
22334            "gate4agent/mw-restart",
22335            Some("HEAD"),
22336        ).await.unwrap();
22337        let lease = ManagedWorktreeLeaseRecord {
22338            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22339            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22340            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22341            profile_id: profile.profile_id().clone(),
22342            profile_revision: profile.revision().clone(),
22343            target_root: created.path.clone(),
22344            branch: "gate4agent/mw-restart".to_owned(),
22345            base_commit: created.head.clone(),
22346            expected_head: None,
22347            retention: ManagedWorktreeRetention::Retain,
22348            state: ManagedWorktreeLeaseState::Allocating,
22349            session_holders: Vec::new(),
22350            record_holders: Vec::new(),
22351            cleanup_failure: None,
22352            created_at_unix_ms: 1,
22353            updated_at_unix_ms: 1,
22354        };
22355        let catalog = active_registry().unwrap();
22356        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22357        let shared = NodeShared::new_with_incarnation(
22358            handle,
22359            "fixture-token".to_owned(),
22360            NodeId::new("node-managed-restart").unwrap(),
22361            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22362            vec![workspace],
22363            vec![agent("claude")],
22364            ProviderRuntimeStatuses::default(),
22365            None,
22366            Vec::new(),
22367            Vec::new(),
22368            SpawnProfileRegistry::default(),
22369            None,
22370            None,
22371            None,
22372            None,
22373            Vec::new(),
22374            vec![lease.clone()],
22375            Vec::new(),
22376            Vec::new(),
22377            None,
22378            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22379        );
22380        shared.reconcile_managed_worktrees().await;
22381        let retained = shared.managed_worktrees.lock()
22382            .unwrap_or_else(|poisoned| poisoned.into_inner())
22383            .get(&lease.lease_id).unwrap().clone();
22384        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22385        assert!(shared.workspaces.read()
22386            .unwrap_or_else(|poisoned| poisoned.into_inner())
22387            .contains_key(&lease.workspace_id));
22388        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22389        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22390        assert!(!Path::new(&created.path).exists());
22391        std::fs::remove_dir_all(root).unwrap();
22392    }
22393
22394    fn run_test_git(root: &Path, arguments: &[&str]) {
22395        let output = std::process::Command::new("git")
22396            .arg("-C").arg(root).args(arguments).output().unwrap();
22397        assert!(
22398            output.status.success(),
22399            "git {:?} failed: {}",
22400            arguments,
22401            String::from_utf8_lossy(&output.stderr),
22402        );
22403    }
22404
22405    #[tokio::test]
22406    async fn spawn_spec_validation_fails_before_session_mutation() {
22407        let shared = terminal_test_shared();
22408        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22409            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22410        }));
22411        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22412            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22413        }));
22414        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22415            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22416        }));
22417
22418        let mut target_mismatch = spawn_spec_fixture();
22419        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22420        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22421        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22422
22423        let mut unknown_profile = spawn_spec_fixture();
22424        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22425        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22426        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22427        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22428
22429        let mut revision_mismatch = spawn_spec_fixture();
22430        revision_mismatch.idempotency_key =
22431            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22432        revision_mismatch.expected_profile_revision =
22433            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22434        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22435        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22436
22437        let mut unsupported_materializer = spawn_spec_fixture();
22438        unsupported_materializer.idempotency_key =
22439            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22440        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22441            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22442        };
22443        let failure = shared
22444            .spawn_from_spec(unsupported_materializer)
22445            .await
22446            .unwrap_err();
22447        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22448
22449        let failure = shared
22450            .spawn_session_with_deadline(
22451                WorkspaceId::new("primary").unwrap(),
22452                agent("claude"),
22453                SessionMode::Pty,
22454                gate4agent_types::TerminalSize {
22455                    rows: 24,
22456                    columns: 80,
22457                },
22458                None,
22459                None,
22460                None,
22461                None,
22462                None,
22463                None,
22464                SpawnRecordPolicy::ProviderIdentityOnly,
22465                Some(Instant::now()),
22466                &[],
22467                None,
22468                ApprovalLevel::default(),
22469                None,
22470                None,
22471            )
22472            .await
22473            .unwrap_err();
22474        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22475        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22476        assert!(shared.handle.snapshot().sessions.is_empty());
22477    }
22478
22479    #[test]
22480    fn worktree_selection_requires_its_separate_negotiated_capability() {
22481        let mut spec = spawn_spec_fixture();
22482        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22483        let request = NodeRequest::SpawnSpec { spec };
22484        let spawn_spec_capability =
22485            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22486        assert!(request_uses_unnegotiated_capability(
22487            &request,
22488            &[spawn_spec_capability.clone()],
22489        ));
22490        let worktree_capability =
22491            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22492        assert!(request_uses_unnegotiated_capability(
22493            &request,
22494            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22495        ));
22496        let profile_revision_capability =
22497            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22498        assert!(request_uses_unnegotiated_capability(
22499            &request,
22500            &[
22501                spawn_spec_capability.clone(),
22502                worktree_capability.clone(),
22503                profile_revision_capability.clone(),
22504            ],
22505        ));
22506        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22507        // `Inherit`, not `Clear` — the profile's own defaults might still
22508        // materialize a bundle or a context pack, so both capabilities stay
22509        // conservatively required until negotiated (see
22510        // `requires_session_bundle_materialization_capability` /
22511        // `requires_history_context_pack_capability`).
22512        let bundle_materialization_capability =
22513            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22514        assert!(request_uses_unnegotiated_capability(
22515            &request,
22516            &[
22517                spawn_spec_capability.clone(),
22518                worktree_capability.clone(),
22519                profile_revision_capability.clone(),
22520                bundle_materialization_capability.clone(),
22521            ],
22522        ));
22523        let history_context_pack_capability =
22524            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22525        assert!(!request_uses_unnegotiated_capability(
22526            &request,
22527            &[
22528                spawn_spec_capability,
22529                worktree_capability,
22530                profile_revision_capability,
22531                bundle_materialization_capability,
22532                history_context_pack_capability,
22533            ],
22534        ));
22535    }
22536
22537    #[tokio::test]
22538    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22539        let mut shared = terminal_test_shared();
22540        let spec = spawn_spec_fixture();
22541        let receipt = shared
22542            .resolve_spawn_spec(&spec)
22543            .unwrap()
22544            .receipt(shared.incarnation_id, terminal_address(1));
22545        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22546
22547        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22548        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22549        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22550
22551        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22552
22553        let mut revision_conflicting = spec.clone();
22554        revision_conflicting.expected_profile_revision =
22555            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22556        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22557        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22558
22559        let mut stale_cache_miss = spec.clone();
22560        stale_cache_miss.idempotency_key =
22561            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22562        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22563        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22564
22565        let mut conflicting = spec;
22566        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22567        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22568        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22569        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22570        assert!(shared.handle.snapshot().sessions.is_empty());
22571    }
22572
22573    #[tokio::test]
22574    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22575        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22576        let mut spec = spawn_spec_fixture();
22577        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22578        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22579        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22580
22581        let failure = shared
22582            .create_worktree(
22583                WorkspaceId::new("primary").unwrap(),
22584                WorkspaceId::new("new-worktree").unwrap(),
22585                "not-an-absolute-path".to_owned(),
22586                "invalid branch".to_owned(),
22587                None,
22588            )
22589            .await
22590            .unwrap_err();
22591        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22592
22593        let failure = shared
22594            .remove_worktree(
22595                WorkspaceId::new("primary").unwrap(),
22596                "not-an-absolute-path".to_owned(),
22597            )
22598            .await
22599            .unwrap_err();
22600        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22601        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22602        assert!(shared.handle.snapshot().sessions.is_empty());
22603    }
22604
22605    #[tokio::test]
22606    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22607        let root = temporary_workspace_root("manual-worktree-compensation");
22608        let source = root.join("source");
22609        let target = root.join("target");
22610        let invalid_state_parent = root.join("state-parent-is-a-file");
22611        let invalid_state_path = invalid_state_parent.join("state.json");
22612        std::fs::create_dir_all(&source).unwrap();
22613        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22614        run_test_git(&source, &["init"]);
22615        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22616        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22617        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22618        run_test_git(&source, &["add", "seed.txt"]);
22619        run_test_git(&source, &["commit", "-m", "seed"]);
22620
22621        let catalog = active_registry().unwrap();
22622        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22623        let workspace = WorkspaceConfig::new(
22624            WorkspaceId::new("primary").unwrap(),
22625            &source,
22626        ).unwrap();
22627        let mut shared = NodeShared::new(
22628            handle,
22629            "fixture-token".to_owned(),
22630            NodeId::new("node-worktree-compensation").unwrap(),
22631            vec![workspace],
22632            vec![agent("claude")],
22633        );
22634        shared.state_path = Some(invalid_state_path);
22635
22636        let error = shared.create_worktree(
22637            WorkspaceId::new("primary").unwrap(),
22638            WorkspaceId::new("compensated").unwrap(),
22639            target.to_string_lossy().into_owned(),
22640            "gate4agent/compensated".to_owned(),
22641            Some("HEAD".to_owned()),
22642        ).await.unwrap_err();
22643
22644        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22645        assert!(!target.exists());
22646        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22647            .iter()
22648            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22649        std::fs::remove_dir_all(root).unwrap();
22650    }
22651
22652    #[test]
22653    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22654        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22655            NativeGitWorktreeSnapshot {
22656                path: path.to_owned(),
22657                head: "0123456789abcdef".to_owned(),
22658                branch: Some("refs/heads/test".to_owned()),
22659                is_bare,
22660                is_main,
22661                locked: false,
22662                lock_reason: None,
22663                prunable: false,
22664                prunable_reason: None,
22665                workspace_id: None,
22666            }
22667        }
22668
22669        let source = r"C:\repo";
22670        let selected = r"C:\repo-worktree";
22671        let listed = vec![
22672            worktree(source, true, false),
22673            worktree(selected, false, false),
22674        ];
22675        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22676
22677        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22678        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22679
22680        let bare = vec![
22681            worktree(source, true, false),
22682            worktree(selected, false, true),
22683        ];
22684        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22685        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22686
22687        let foreign_failure =
22688            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22689        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22690    }
22691
22692    fn terminal_frame(sequence: u64) -> TerminalFrame {
22693        TerminalFrame {
22694            sequence,
22695            size: gate4agent_types::TerminalSize {
22696                rows: 24,
22697                columns: 80,
22698            },
22699            cursor_row: 2,
22700            cursor_column: 3,
22701            contents: format!("frame-{sequence}"),
22702            formatted: vec![1, 2, 3],
22703            scrollback_formatted: Vec::new(),
22704            alternate_screen: false,
22705            mouse_protocol_enabled: false,
22706            mouse_protocol_encoding: Default::default(),
22707            produced_at_unix_ms: 0,
22708            screen_state: gate4agent_types::PtyScreenState::default(),
22709            bracketed_paste: None,
22710        }
22711    }
22712
22713    #[test]
22714    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22715        let shared = terminal_test_shared();
22716        let mut receiver = shared.terminal_event_tx.subscribe();
22717        let original = terminal_address(1);
22718
22719        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22720        let first = receiver.try_recv().unwrap();
22721        assert_eq!(first.len(), 1);
22722        assert!(matches!(
22723            &first[0].event,
22724            NodeEvent::TerminalFrame { address, frame }
22725                if address == &original && frame.sequence == 7
22726        ));
22727
22728        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22729        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22730        assert!(matches!(
22731            receiver.try_recv(),
22732            Err(broadcast::error::TryRecvError::Empty)
22733        ));
22734
22735        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22736        let advanced = receiver.try_recv().unwrap();
22737        assert_eq!(advanced[0].sequence, 2);
22738
22739        let resumed = terminal_address(2);
22740        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22741        let reset = receiver.try_recv().unwrap();
22742        assert_eq!(reset[0].sequence, 3);
22743        assert!(matches!(
22744            &reset[0].event,
22745            NodeEvent::TerminalFrame { address, frame }
22746                if address == &resumed && frame.sequence == 1
22747        ));
22748        shared.clear_terminal_frame_watermark(&resumed);
22749        assert!(!shared
22750            .terminal_frame_watermarks
22751            .lock()
22752            .unwrap_or_else(|poisoned| poisoned.into_inner())
22753            .contains_key(&resumed.session.instance_id));
22754    }
22755
22756    #[test]
22757    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22758        let shared = terminal_test_shared();
22759        let mut receiver = shared.terminal_event_tx.subscribe();
22760        let address = terminal_address(1);
22761        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22762
22763        for sequence in 1..=count {
22764            shared.publish_terminal_frame_candidates(vec![(
22765                address.clone(),
22766                terminal_frame(sequence),
22767            )]);
22768        }
22769
22770        let history = shared
22771            .history
22772            .lock()
22773            .unwrap_or_else(|poisoned| poisoned.into_inner());
22774        assert!(history.events.is_empty());
22775        assert_eq!(history.last_sequence, count);
22776        assert_eq!(history.replay_floor_sequence, 1);
22777        drop(history);
22778        let NodeResponse::Resync {
22779            event_sequence,
22780            oldest_available_sequence,
22781            events,
22782            ..
22783        } = shared.resync(0) else {
22784            unreachable!("resync helper returned another response")
22785        };
22786        assert_eq!(event_sequence, count);
22787        assert_eq!(oldest_available_sequence, 1);
22788        assert!(events.is_empty());
22789        assert!(matches!(
22790            resync_required_event(&shared).event,
22791            NodeEvent::ResyncRequired {
22792                oldest_available_sequence: 1,
22793            }
22794        ));
22795        assert!(matches!(
22796            receiver.try_recv(),
22797            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22798        ));
22799        let latest = receiver.try_recv().unwrap();
22800        assert_eq!(latest.len(), 1);
22801        assert_eq!(latest[0].sequence, count);
22802    }
22803
22804    #[test]
22805    fn real_durable_eviction_advances_authoritative_replay_floor() {
22806        let shared = terminal_test_shared();
22807        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22808            shared.publish(NodeEvent::ControllerChanged { controller: None });
22809        }
22810
22811        let NodeResponse::Resync {
22812            event_sequence,
22813            oldest_available_sequence,
22814            events,
22815            ..
22816        } = shared.resync(0) else {
22817            unreachable!("resync helper returned another response")
22818        };
22819        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22820        assert_eq!(oldest_available_sequence, 2);
22821        assert_eq!(events.first().map(|event| event.sequence), Some(2));
22822        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22823        assert!(matches!(
22824            resync_required_event(&shared).event,
22825            NodeEvent::ResyncRequired {
22826                oldest_available_sequence: 2,
22827            }
22828        ));
22829    }
22830
22831    #[test]
22832    fn legacy_terminal_frame_connection_has_no_live_subscription() {
22833        let shared = terminal_test_shared();
22834        let without_capability = terminal_event_subscription(&shared, false);
22835        let with_capability = terminal_event_subscription(&shared, true);
22836        assert!(without_capability.is_none());
22837        assert!(with_capability.is_some());
22838        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22839            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22840        }));
22841    }
22842
22843    #[test]
22844    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22845        let mut pending = Vec::new();
22846        let mut event_sequence = 0_u64;
22847        for frame_sequence in 1..=4 {
22848            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22849                event_sequence += 1;
22850                let mut address = terminal_address(1);
22851                address.session.instance_id = AgentInstanceId(instance);
22852                queue_connection_event(
22853                    &mut pending,
22854                    NodeEventEnvelope {
22855                        sequence: event_sequence,
22856                        event: NodeEvent::TerminalFrame {
22857                            address,
22858                            frame: terminal_frame(frame_sequence),
22859                        },
22860                    },
22861                    0,
22862                );
22863            }
22864        }
22865        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22866        pending.sort_unstable_by_key(|event| event.sequence);
22867
22868        let mut uninterrupted = pending.clone();
22869        let first_burst = connection_event_burst_len(uninterrupted.len());
22870        uninterrupted.drain(..first_burst);
22871        let second_burst = connection_event_burst_len(uninterrupted.len());
22872        uninterrupted.drain(..second_burst);
22873        assert!(uninterrupted.is_empty());
22874
22875        let (frame_tx, mut frame_rx) =
22876            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22877        frame_tx
22878            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22879                request_id: 77,
22880                request: NodeRequest::Snapshot,
22881            })))
22882            .unwrap();
22883        let burst_len = connection_event_burst_len(pending.len());
22884        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22885        pending.drain(..burst_len);
22886        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22887        assert!(matches!(
22888            frame_rx.try_recv(),
22889            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22890                request_id: 77,
22891                request: NodeRequest::Snapshot,
22892            })))
22893        ));
22894    }
22895
22896    #[test]
22897    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22898        let mut pending = Vec::new();
22899        for sequence in [7_u64, 8] {
22900            let mut address = terminal_address(1);
22901            address.session.instance_id = AgentInstanceId(sequence);
22902            queue_connection_event(
22903                &mut pending,
22904                NodeEventEnvelope {
22905                    sequence,
22906                    event: NodeEvent::TerminalFrame {
22907                        address,
22908                        frame: terminal_frame(sequence),
22909                    },
22910                },
22911                0,
22912            );
22913        }
22914        let mut discard_through = 0;
22915        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22916        assert!(pending.is_empty());
22917
22918        let replayed = NodeEventEnvelope {
22919            sequence: 8,
22920            event: NodeEvent::TerminalFrame {
22921                address: terminal_address(1),
22922                frame: terminal_frame(8),
22923            },
22924        };
22925        let advanced = NodeEventEnvelope {
22926            sequence: 9,
22927            event: NodeEvent::TerminalFrame {
22928                address: terminal_address(2),
22929                frame: terminal_frame(1),
22930            },
22931        };
22932        queue_connection_event_batch(
22933            &mut pending,
22934            &[replayed, advanced.clone()],
22935            discard_through,
22936        );
22937        assert_eq!(pending, vec![advanced]);
22938    }
22939
22940    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22941        ManagedSessionRecord {
22942            record_id: SessionRecordId::new(record_id).unwrap(),
22943            display_name: format!("{provider} record"),
22944            provider: agent(provider),
22945            mode: SessionMode::Pty,
22946            state: ManagedSessionState::Unavailable,
22947            workspace_id: WorkspaceId::new("primary").unwrap(),
22948            canonical_root: opaque_windows_path(
22949                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22950            ),
22951            provider_session: None,
22952            active_session: None,
22953            environment_profile: None,
22954            bundle: None,
22955            context_id: None,
22956            context: None,
22957            exported_context: None,
22958            task_binding: None,
22959            created_at_unix_ms: 1,
22960            updated_at_unix_ms: 1,
22961            last_error: None,
22962        }
22963    }
22964
22965    fn retention_test_record(
22966        workspace_id: &str,
22967        record_id: &str,
22968        state: ManagedSessionState,
22969        updated_at_unix_ms: u64,
22970    ) -> ManagedSessionRecord {
22971        ManagedSessionRecord {
22972            record_id: SessionRecordId::new(record_id).unwrap(),
22973            display_name: format!("{record_id} display"),
22974            provider: agent("claude"),
22975            mode: SessionMode::Pty,
22976            state,
22977            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22978            canonical_root: opaque_windows_path(
22979                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22980            ),
22981            provider_session: None,
22982            active_session: None,
22983            environment_profile: None,
22984            bundle: None,
22985            context_id: None,
22986            context: None,
22987            exported_context: None,
22988            task_binding: None,
22989            created_at_unix_ms: updated_at_unix_ms,
22990            updated_at_unix_ms,
22991            last_error: None,
22992        }
22993    }
22994
22995    /// Two workspaces of three `Unavailable` records each, staggered by
22996    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22997    /// `workspace-a` (all as old as that workspace's oldest real record --
22998    /// if the sweep's state gate ever leaked, these would be the first
22999    /// thing it wrongly retired). Ages, oldest to newest:
23000    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23001    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23002    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23003        vec![
23004            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23005            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23006            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23007            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23008            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23009            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23010            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23011            retention_test_record(
23012                "workspace-a",
23013                "decoy-pending",
23014                ManagedSessionState::IdentityPending,
23015                now - 500_000,
23016            ),
23017            retention_test_record(
23018                "workspace-a",
23019                "decoy-dormant",
23020                ManagedSessionState::Dormant,
23021                now - 500_000,
23022            ),
23023        ]
23024    }
23025
23026    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23027        let root = temporary_workspace_root("session-record-retention");
23028        std::fs::create_dir_all(&root).unwrap();
23029        let catalog = active_registry().unwrap();
23030        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23031        let workspace_a =
23032            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23033        let workspace_b =
23034            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23035        let mut shared = NodeShared::new(
23036            handle,
23037            "fixture-token".to_owned(),
23038            NodeId::new("node-retention-test").unwrap(),
23039            vec![workspace_a, workspace_b],
23040            vec![agent("claude")],
23041        );
23042        shared.state_path = Some(root.join("node-state-v10.json"));
23043        let now = unix_time_ms();
23044        for record in retention_fixture_records(now) {
23045            shared.insert_record(record).unwrap();
23046        }
23047        shared
23048            .persist_state_locked()
23049            .expect("fixture-only, empty-history persist against a fresh temp state path");
23050        (shared, runtime, root, now)
23051    }
23052
23053    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23054        shared
23055            .history
23056            .lock()
23057            .unwrap_or_else(|poisoned| poisoned.into_inner())
23058            .events
23059            .iter()
23060            .filter_map(|envelope| match &envelope.event {
23061                NodeEvent::SessionRecordRemoved { record_id } => {
23062                    Some(record_id.as_str().to_owned())
23063                }
23064                _ => None,
23065            })
23066            .collect()
23067    }
23068
23069    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23070        shared
23071            .session_records
23072            .lock()
23073            .unwrap_or_else(|poisoned| poisoned.into_inner())
23074            .records
23075            .keys()
23076            .map(|record_id| record_id.as_str().to_owned())
23077            .collect()
23078    }
23079
23080    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23081        values.iter().map(|value| (*value).to_owned()).collect()
23082    }
23083
23084    #[tokio::test]
23085    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23086        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23087        shared.session_record_retention = SessionRecordRetentionConfig {
23088            age_ms: 0,
23089            keep_per_workspace: 1,
23090        };
23091        shared.retire_unavailable_session_records().await;
23092
23093        assert_eq!(
23094            retention_remaining_record_ids(&shared),
23095            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23096        );
23097        assert_eq!(
23098            retention_removed_record_ids(&shared),
23099            owned(&["a1", "a2", "b1", "b2"]),
23100        );
23101        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23102
23103        drop(shared);
23104        drop(runtime);
23105        std::fs::remove_dir_all(root).unwrap();
23106    }
23107
23108    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23109    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23110    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23111    /// 200s old against a 250s threshold) even though it fails the keep-N
23112    /// test too, proving retirement needs BOTH, not either.
23113    #[tokio::test]
23114    async fn session_record_retention_both_set_retires_only_the_intersection() {
23115        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23116        shared.session_record_retention = SessionRecordRetentionConfig {
23117            age_ms: 250_000,
23118            keep_per_workspace: 1,
23119        };
23120        shared.retire_unavailable_session_records().await;
23121
23122        assert_eq!(
23123            retention_remaining_record_ids(&shared),
23124            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23125        );
23126        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23127        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23128
23129        drop(shared);
23130        drop(runtime);
23131        std::fs::remove_dir_all(root).unwrap();
23132    }
23133
23134    #[tokio::test]
23135    async fn session_record_retention_both_disabled_is_a_no_op() {
23136        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23137        shared.session_record_retention = SessionRecordRetentionConfig::default();
23138        shared.retire_unavailable_session_records().await;
23139
23140        assert_eq!(
23141            retention_remaining_record_ids(&shared),
23142            owned(&[
23143                "a1", "a2", "a3", "b1", "b2", "b3",
23144                "decoy-live", "decoy-pending", "decoy-dormant",
23145            ]),
23146        );
23147        assert!(retention_removed_record_ids(&shared).is_empty());
23148        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23149
23150        drop(shared);
23151        drop(runtime);
23152        std::fs::remove_dir_all(root).unwrap();
23153    }
23154
23155    #[test]
23156    fn session_record_context_export_selects_one_exact_history_identity() {
23157        let candidates = vec![
23158            HistoryCandidateSummary {
23159                id: "candidate-other".to_owned(),
23160                session_id_hint: "provider-other".to_owned(),
23161                modified_at_unix_ms: Some(1),
23162            },
23163            HistoryCandidateSummary {
23164                id: "candidate-exact".to_owned(),
23165                session_id_hint: "provider-exact".to_owned(),
23166                modified_at_unix_ms: Some(2),
23167            },
23168        ];
23169        assert_eq!(
23170            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23171            "candidate-exact",
23172        );
23173    }
23174
23175    #[test]
23176    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23177        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23178            id: id.to_owned(),
23179            session_id_hint: hint.to_owned(),
23180            modified_at_unix_ms: None,
23181        };
23182        let missing = select_history_candidate(
23183            &[candidate("candidate-other", "provider-other")],
23184            Some("provider-exact"),
23185        )
23186        .unwrap_err();
23187        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23188        let ambiguous = select_history_candidate(
23189            &[
23190                candidate("candidate-a", "provider-exact"),
23191                candidate("candidate-b", "provider-exact"),
23192            ],
23193            Some("provider-exact"),
23194        )
23195        .unwrap_err();
23196        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23197    }
23198
23199    /// The PTY-sidecar-observation shape (no fleet provider today, but
23200    /// capability-keyed rather than vendor-locked) never carries a verified
23201    /// `provider_session`; its record-bound export instead falls back to
23202    /// "exactly one discovered candidate" (see
23203    /// `export_context_pack_for_session_record_inner`). This is the same
23204    /// ambiguity refusal as the identity-filtered path, just without an
23205    /// identity to filter by.
23206    #[test]
23207    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23208        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23209            id: id.to_owned(),
23210            session_id_hint: hint.to_owned(),
23211            modified_at_unix_ms: None,
23212        };
23213        assert_eq!(
23214            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23215                .unwrap(),
23216            "candidate-sole",
23217        );
23218        let missing = select_history_candidate(&[], None).unwrap_err();
23219        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23220        let ambiguous = select_history_candidate(
23221            &[
23222                candidate("candidate-a", "sidecar-session-a"),
23223                candidate("candidate-b", "sidecar-session-b"),
23224            ],
23225            None,
23226        )
23227        .unwrap_err();
23228        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23229    }
23230
23231    #[test]
23232    fn session_record_context_export_revalidation_rejects_record_change() {
23233        let session = terminal_address(1);
23234        let identity = ProviderSessionIdentity {
23235            key: ProviderSessionKey::SessionId,
23236            id: "provider-exact".to_owned(),
23237            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23238        };
23239        let mut expected = record("claude", "record-context-export");
23240        expected.state = ManagedSessionState::Live;
23241        expected.provider_session = Some(identity.clone());
23242        expected.active_session = Some(session.clone());
23243        let binding = SessionBinding {
23244            workspace_id: session.workspace_id.clone(),
23245            generation: session.session.generation,
23246            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23247            pending_resume: None,
23248            record_id: Some(expected.record_id.clone()),
23249            managed_worktree_lease_id: None,
23250            environment_profile: None,
23251            bundle: None,
23252            context: None,
23253            materialization_id: None,
23254        };
23255        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23256        assert!(session_record_context_export_binding_is_exact(
23257            &expected,
23258            &expected,
23259            Some(&identity),
23260            &session,
23261            &binding,
23262            &expected.provider,
23263            &current_root,
23264            false,
23265        ));
23266        let mut changed = expected.clone();
23267        changed.updated_at_unix_ms += 1;
23268        assert!(!session_record_context_export_binding_is_exact(
23269            &expected,
23270            &changed,
23271            Some(&identity),
23272            &session,
23273            &binding,
23274            &expected.provider,
23275            &current_root,
23276            false,
23277        ));
23278        // A same-instant timestamp bump alone (state and active_session
23279        // otherwise unchanged) is not the clean-detachment transition
23280        // either, even with the reactive flag on: only the exact Live ->
23281        // Dormant + active_session cleared shape qualifies.
23282        assert!(!session_record_context_export_binding_is_exact(
23283            &expected,
23284            &changed,
23285            Some(&identity),
23286            &session,
23287            &binding,
23288            &expected.provider,
23289            &current_root,
23290            true,
23291        ));
23292        let mut detached = expected.clone();
23293        detached.state = ManagedSessionState::Dormant;
23294        detached.active_session = None;
23295        detached.updated_at_unix_ms += 1;
23296        assert!(!session_record_context_export_binding_is_exact(
23297            &expected,
23298            &detached,
23299            Some(&identity),
23300            &session,
23301            &binding,
23302            &expected.provider,
23303            &current_root,
23304            false,
23305        ));
23306        assert!(session_record_context_export_binding_is_exact(
23307            &expected,
23308            &detached,
23309            Some(&identity),
23310            &session,
23311            &binding,
23312            &expected.provider,
23313            &current_root,
23314            true,
23315        ));
23316    }
23317
23318    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23319    /// for a record whose provider never carries a verified identity (the
23320    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23321    /// declares one today): `identity` is `None` throughout, and the
23322    /// exactness check falls back to comparing `provider_session` itself,
23323    /// which must still be `None` on both sides.
23324    #[test]
23325    fn session_record_context_export_binding_is_exact_without_identity() {
23326        let session = terminal_address(1);
23327        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23328        expected.state = ManagedSessionState::Live;
23329        expected.active_session = Some(session.clone());
23330        let binding = SessionBinding {
23331            workspace_id: session.workspace_id.clone(),
23332            generation: session.session.generation,
23333            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23334            pending_resume: None,
23335            record_id: Some(expected.record_id.clone()),
23336            managed_worktree_lease_id: None,
23337            environment_profile: None,
23338            bundle: None,
23339            context: None,
23340            materialization_id: None,
23341        };
23342        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23343        assert!(session_record_context_export_binding_is_exact(
23344            &expected,
23345            &expected,
23346            None,
23347            &session,
23348            &binding,
23349            &expected.provider,
23350            &current_root,
23351            false,
23352        ));
23353        // A record that gained an identity between the initial lookup and
23354        // this recheck no longer matches the `None` this shape expects to
23355        // keep seeing -- treated the same as any other mid-export drift.
23356        let mut drifted = expected.clone();
23357        drifted.provider_session = Some(ProviderSessionIdentity {
23358            key: ProviderSessionKey::SessionId,
23359            id: "unexpected-identity".to_owned(),
23360            transcript_path: None,
23361        });
23362        assert!(!session_record_context_export_binding_is_exact(
23363            &expected,
23364            &drifted,
23365            None,
23366            &session,
23367            &binding,
23368            &expected.provider,
23369            &current_root,
23370            false,
23371        ));
23372    }
23373
23374    #[test]
23375    fn session_record_context_export_preflight_requires_live_usable_source() {
23376        let mut source = record("claude", "record-context-export-preflight");
23377        source.state = ManagedSessionState::Live;
23378        assert!(session_record_context_export_source_is_usable(
23379            &source,
23380            &SessionStatus::Running,
23381            false,
23382        ));
23383        assert!(session_record_context_export_source_is_usable(
23384            &source,
23385            &SessionStatus::Exited { exit_code: Some(0) },
23386            false,
23387        ));
23388
23389        source.state = ManagedSessionState::Dormant;
23390        assert!(!session_record_context_export_source_is_usable(
23391            &source,
23392            &SessionStatus::Running,
23393            false,
23394        ));
23395        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23396        // the only caller that may treat a Dormant, cleanly-exited record as
23397        // still usable — the on-demand path above never does.
23398        assert!(session_record_context_export_source_is_usable(
23399            &source,
23400            &SessionStatus::Exited { exit_code: Some(0) },
23401            true,
23402        ));
23403        assert!(!session_record_context_export_source_is_usable(
23404            &source,
23405            &SessionStatus::Exited { exit_code: Some(0) },
23406            false,
23407        ));
23408        source.state = ManagedSessionState::Live;
23409        assert!(!session_record_context_export_source_is_usable(
23410            &source,
23411            &SessionStatus::Starting,
23412            false,
23413        ));
23414    }
23415
23416    #[test]
23417    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23418        let catalog = active_registry().unwrap();
23419        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23420        let workspace = WorkspaceConfig::new(
23421            WorkspaceId::new("primary").unwrap(),
23422            std::env::current_dir().unwrap(),
23423        )
23424        .unwrap();
23425        let shared = NodeShared::new(
23426            handle,
23427            "fixture-token".to_owned(),
23428            NodeId::new("node-context-export-conflict").unwrap(),
23429            vec![workspace],
23430            vec![agent("claude")],
23431        );
23432        let history = HistorySessionRecord {
23433            session_id: "provider-exact".to_owned(),
23434            title: None,
23435            cwd: None,
23436            model: None,
23437            message_count: 1,
23438            completed_turn_count: None,
23439            total_tokens: 0,
23440            messages: vec![gate4agent_types::HistoryMessageRecord {
23441                role: gate4agent_types::HistoryMessageRole::User,
23442                text: "bounded context".to_owned(),
23443            }],
23444        };
23445        let pack = NodeContextPack::export(
23446            ContextPackLineageReceipt {
23447                source_node_id: shared.node_id.clone(),
23448                source_session: terminal_address(1),
23449                source_provider: agent("claude"),
23450            },
23451            &history,
23452        )
23453        .unwrap();
23454        let context_id = pack.receipt().id.clone();
23455        let session = terminal_address(1);
23456        let identity = ProviderSessionIdentity {
23457            key: ProviderSessionKey::SessionId,
23458            id: history.session_id.clone(),
23459            transcript_path: None,
23460        };
23461        let mut expected = record("claude", "record-context-export-final-conflict");
23462        expected.state = ManagedSessionState::Live;
23463        expected.provider_session = Some(identity.clone());
23464        expected.active_session = Some(session.clone());
23465        let mut changed = expected.clone();
23466        changed.updated_at_unix_ms += 1;
23467        shared.insert_record(changed).unwrap();
23468
23469        let error = shared
23470            .commit_context_pack_for_session_record(
23471                &expected,
23472                Some(&identity),
23473                &session,
23474                "candidate-exact",
23475                &history,
23476                pack,
23477                false,
23478            )
23479            .unwrap_err();
23480
23481        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23482        assert!(shared
23483            .context_catalog
23484            .read()
23485            .unwrap_or_else(|poisoned| poisoned.into_inner())
23486            .get(&context_id)
23487            .is_none());
23488    }
23489
23490    #[tokio::test]
23491    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23492        let root = temporary_workspace_root("session-task-binding");
23493        std::fs::create_dir_all(&root).unwrap();
23494        let state_path = root.join("node-state.json");
23495        let catalog = active_registry().unwrap();
23496        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23497        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23498        let node_id = NodeId::new("node-1").unwrap();
23499        let mut shared = NodeShared::new(
23500            handle,
23501            "fixture-token".to_owned(),
23502            node_id.clone(),
23503            vec![workspace],
23504            vec![agent("claude")],
23505        );
23506        shared.state_path = Some(state_path.clone());
23507        let record = record("claude", "sr-task");
23508        let record_id = record.record_id.clone();
23509        shared.insert_record(record).unwrap();
23510        shared.persist_state().unwrap();
23511        shared
23512            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23513            .unwrap();
23514
23515        let minted = process_request_inner(
23516            &shared,
23517            77,
23518            ClientRole::Operator,
23519            NodeRequest::SetSessionTask {
23520                record_id: record_id.clone(),
23521                expected_revision: 0,
23522                target: SessionTaskTargetV1::New,
23523            },
23524        )
23525        .await
23526        .unwrap();
23527        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23528            panic!("session task mutation returned another response");
23529        };
23530        let minted_binding = minted.task_binding.clone().unwrap();
23531        let minted_task_id = minted_binding.task_id.clone().unwrap();
23532        assert_eq!(minted_binding.revision, 1);
23533        assert!(minted_task_id.as_str().starts_with("task-"));
23534        assert_eq!(minted_task_id.as_str().len(), 29);
23535
23536        let event_count = shared.current_sequence();
23537        let repeated = process_request_inner(
23538            &shared,
23539            77,
23540            ClientRole::Operator,
23541            NodeRequest::SetSessionTask {
23542                record_id: record_id.clone(),
23543                expected_revision: 1,
23544                target: SessionTaskTargetV1::Existing {
23545                    task_id: minted_task_id.clone(),
23546                },
23547            },
23548        )
23549        .await
23550        .unwrap();
23551        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23552        assert_eq!(shared.current_sequence(), event_count);
23553
23554        let stale = process_request_inner(
23555            &shared,
23556            77,
23557            ClientRole::Operator,
23558            NodeRequest::SetSessionTask {
23559                record_id: record_id.clone(),
23560                expected_revision: 0,
23561                target: SessionTaskTargetV1::Existing {
23562                    task_id: minted_task_id,
23563                },
23564            },
23565        )
23566        .await
23567        .unwrap_err();
23568        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23569
23570        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23571        let assigned = process_request_inner(
23572            &shared,
23573            77,
23574            ClientRole::Operator,
23575            NodeRequest::SetSessionTask {
23576                record_id: record_id.clone(),
23577                expected_revision: 1,
23578                target: SessionTaskTargetV1::Existing {
23579                    task_id: assigned_id.clone(),
23580                },
23581            },
23582        )
23583        .await
23584        .unwrap();
23585        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23586            panic!("session task assignment returned another response");
23587        };
23588        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23589        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23590
23591        let cleared = process_request_inner(
23592            &shared,
23593            77,
23594            ClientRole::Operator,
23595            NodeRequest::SetSessionTask {
23596                record_id: record_id.clone(),
23597                expected_revision: 2,
23598                target: SessionTaskTargetV1::Clear,
23599            },
23600        )
23601        .await
23602        .unwrap();
23603        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23604            panic!("session task clear returned another response");
23605        };
23606        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23607        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23608        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23609        assert_eq!(
23610            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23611            cleared.updated_at_unix_ms,
23612        );
23613
23614        let persisted = std::fs::read(&state_path).unwrap();
23615        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23616        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23617        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23618        std::fs::remove_dir_all(root).unwrap();
23619    }
23620
23621    #[test]
23622    fn session_task_capability_strips_snapshots_events_and_replies() {
23623        let mut bound = record("claude", "sr-task-projection");
23624        bound.updated_at_unix_ms = 2;
23625        bound.task_binding = Some(SessionTaskBindingV1 {
23626            revision: 1,
23627            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23628            changed_at_unix_ms: 2,
23629        });
23630        let request = NodeRequest::SetSessionTask {
23631            record_id: bound.record_id.clone(),
23632            expected_revision: 1,
23633            target: SessionTaskTargetV1::Clear,
23634        };
23635        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23636            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23637        }));
23638        assert!(!request_uses_unnegotiated_capability(
23639            &request,
23640            &baseline_capabilities().unwrap(),
23641        ));
23642        assert!(request_uses_unnegotiated_capability(&request, &[]));
23643
23644        let mut snapshot = terminal_test_shared().snapshot();
23645        snapshot.session_records.push(bound.clone());
23646        let mut reply = ResponseEnvelope {
23647            request_id: 1,
23648            result: Ok(NodeResponse::Resync {
23649                event_sequence: 1,
23650                oldest_available_sequence: 1,
23651                snapshot,
23652                events: vec![NodeEventEnvelope {
23653                    sequence: 1,
23654                    event: NodeEvent::SessionRecordUpserted {
23655                        record: bound.clone(),
23656                    },
23657                }],
23658            }),
23659        };
23660        project_response_without_session_task_binding(&mut reply);
23661        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23662            panic!("task projection changed the response kind");
23663        };
23664        assert!(snapshot.session_records[0].task_binding.is_none());
23665        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23666            panic!("task projection changed the event kind");
23667        };
23668        assert!(record.task_binding.is_none());
23669
23670        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23671            sequence: 2,
23672            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23673        });
23674        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23675            panic!("task event projection changed the event kind");
23676        };
23677        assert!(record.task_binding.is_none());
23678
23679        let mut direct = ResponseEnvelope {
23680            request_id: 2,
23681            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23682        };
23683        project_response_without_session_task_binding(&mut direct);
23684        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23685            panic!("task reply projection changed the response kind");
23686        };
23687        assert!(record.task_binding.is_none());
23688    }
23689
23690    #[test]
23691    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23692        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23693        let workspace = WorkspaceConfig::new(
23694            WorkspaceId::new("primary").unwrap(),
23695            std::env::current_dir().unwrap(),
23696        )
23697        .unwrap();
23698        let config = NodeServerConfig::new(
23699            DEFAULT_NODE_ENDPOINT,
23700            "fixture-token",
23701            NodeId::new("node-1").unwrap(),
23702            [workspace],
23703        )
23704        .unwrap();
23705        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23706    }
23707
23708    #[cfg(all(feature = "fixture", windows))]
23709    #[test]
23710    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23711        let root = temporary_workspace_root("provider-bundle-argv-hold");
23712        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23713        std::fs::create_dir_all(&root).unwrap();
23714        std::fs::create_dir_all(&outside).unwrap();
23715        let proof_path = root.join("provider.proof");
23716        let release_signal = root.join("release.signal");
23717        let config = NodeServerConfig::new(
23718            format!(
23719                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23720                std::process::id(),
23721            ),
23722            "fixture-token",
23723            NodeId::new("provider-bundle-hold-node").unwrap(),
23724            [WorkspaceConfig::new(
23725                WorkspaceId::new("primary").unwrap(),
23726                std::env::current_dir().unwrap(),
23727            )
23728            .unwrap()],
23729        )
23730        .unwrap()
23731        .with_spawn_profiles(SpawnProfileRegistry::new([
23732            crate::protocol::SpawnProfileDefaults {
23733                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23734                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23735                    .unwrap(),
23736                provider: agent("kimi"),
23737                mode: SessionMode::Pty,
23738                terminal_size: gate4agent_types::TerminalSize {
23739                    rows: 24,
23740                    columns: 80,
23741                },
23742                prompt: None,
23743                bundle_id: None,
23744                context_id: None,
23745                environment_profile_id: None,
23746            },
23747        ])
23748        .unwrap());
23749        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23750            config,
23751            agent("kimi"),
23752            proof_path.clone(),
23753            release_signal.clone(),
23754        )
23755        .unwrap();
23756        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23757
23758        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23759            agent("kimi"),
23760            proof_path.clone(),
23761            Some(release_signal.clone()),
23762        )
23763        .unwrap();
23764        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23765        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23766        let release_wait = wrapper
23767            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23768            .unwrap();
23769        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23770        assert!(proof_write < release_wait);
23771        assert!(release_wait < fixture_ready);
23772        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23773        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23774        assert_eq!(
23775            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23776            &[
23777                proof_path.to_string_lossy().into_owned(),
23778                release_signal.to_string_lossy().into_owned(),
23779            ],
23780        );
23781
23782        let error = NodeServer::provider_bundle_argv_fixture_spec(
23783            agent("kimi"),
23784            proof_path,
23785            Some(outside.join("release.signal")),
23786        )
23787        .unwrap_err();
23788        assert!(matches!(
23789            error,
23790            NodeServerError::Registry(message)
23791                if message == "provider bundle argv release path must share the proof parent"
23792        ));
23793        drop(server);
23794        std::fs::remove_dir_all(root).unwrap();
23795        std::fs::remove_dir_all(outside).unwrap();
23796    }
23797
23798    #[cfg(feature = "fixture")]
23799    #[test]
23800    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23801        let root = temporary_workspace_root("context-fixture-registry");
23802        std::fs::create_dir_all(&root).unwrap();
23803        let proof_path = root.join("context.proof");
23804        let config = NodeServerConfig::new(
23805            format!(
23806                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23807                std::process::id(),
23808            ),
23809            "fixture-token",
23810            NodeId::new("context-fixture-node").unwrap(),
23811            [WorkspaceConfig::new(
23812                WorkspaceId::new("primary").unwrap(),
23813                std::env::current_dir().unwrap(),
23814            )
23815            .unwrap()],
23816        )
23817        .unwrap();
23818        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23819        let mut enabled = server
23820            .shared
23821            .enabled_providers
23822            .iter()
23823            .map(AgentId::as_str)
23824            .collect::<Vec<_>>();
23825        enabled.sort_unstable();
23826        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23827        for provider in &server.shared.enabled_providers {
23828            assert!(server
23829                .shared
23830                .provider_adapter_contracts
23831                .iter()
23832                .any(|contract| {
23833                    &contract.provider == provider
23834                        && contract.family == AdapterFamily::History
23835                }));
23836        }
23837        drop(server);
23838        std::fs::remove_dir_all(root).unwrap();
23839    }
23840
23841    #[cfg(feature = "fixture")]
23842    #[test]
23843    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23844        let root = temporary_workspace_root("monitoring-context-fixture");
23845        std::fs::create_dir_all(&root).unwrap();
23846        let proof_path = root.join("context.proof");
23847        let standard = NodeServer::context_pack_fixture_catalog(
23848            proof_path.clone(),
23849            None,
23850            false,
23851        ).unwrap();
23852        let monitoring = NodeServer::context_pack_fixture_catalog(
23853            proof_path.clone(),
23854            None,
23855            true,
23856        ).unwrap();
23857        let claude = AgentId::new("claude").unwrap();
23858        let standard_claude = standard.get(&claude).unwrap();
23859        let monitoring_claude = monitoring.get(&claude).unwrap();
23860        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23861        // standard nor the monitoring fixture declares a hook adapter
23862        // anymore.
23863        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23864        assert_eq!(
23865            monitoring_claude.capabilities.adapters.history,
23866            standard_claude.capabilities.adapters.history,
23867        );
23868        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23869        let codex = AgentId::new("codex").unwrap();
23870        let standard_codex = standard.get(&codex).unwrap();
23871        let monitoring_codex = monitoring.get(&codex).unwrap();
23872        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23873        assert_eq!(
23874            monitoring_codex.capabilities.adapters.history,
23875            standard_codex.capabilities.adapters.history,
23876        );
23877        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23878        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23879            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23880            .unwrap();
23881        for required in [
23882            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23883            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23884            "@('role', 'text')",
23885            "g4a-private-provider-session-canary",
23886            "private-provider-login-identity",
23887            "$rawProof",
23888            "$env:GATE4AGENT_HOOK_URL",
23889            "g4a-private-provider-session-canary",
23890        ] {
23891            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23892        }
23893        for provider in ["grok", "kimi"] {
23894            let provider = AgentId::new(provider).unwrap();
23895            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23896        }
23897
23898        let config = NodeServerConfig::new(
23899            format!(
23900                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23901                std::process::id(),
23902            ),
23903            "fixture-token",
23904            NodeId::new("monitoring-context-fixture-node").unwrap(),
23905            [WorkspaceConfig::new(
23906                WorkspaceId::new("primary").unwrap(),
23907                std::env::current_dir().unwrap(),
23908            )
23909            .unwrap()],
23910        )
23911        .unwrap();
23912        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23913        assert!(server.shared.fixture_semantic_hook_policy);
23914        drop(server);
23915        std::fs::remove_dir_all(root).unwrap();
23916    }
23917
23918    #[cfg(feature = "fixture")]
23919    #[test]
23920    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23921        let root = temporary_workspace_root("kimi-context-only-fixture");
23922        std::fs::create_dir_all(&root).unwrap();
23923        let proof_path = root.join("context.proof");
23924        let standard = NodeServer::context_pack_fixture_catalog(
23925            proof_path.clone(),
23926            None,
23927            false,
23928        ).unwrap();
23929        let kimi = AgentId::new("kimi").unwrap();
23930        let context_only = NodeServer::context_pack_fixture_catalog(
23931            proof_path,
23932            Some(&kimi),
23933            false,
23934        )
23935        .unwrap();
23936
23937        let codex = AgentId::new("codex").unwrap();
23938        assert_eq!(
23939            standard.get(&codex).unwrap().launch.fixed_args,
23940            context_only.get(&codex).unwrap().launch.fixed_args,
23941        );
23942        let script = context_only
23943            .get(&kimi)
23944            .unwrap()
23945            .launch
23946            .fixed_args
23947            .iter()
23948            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23949            .expect("Kimi context-only validation script is missing");
23950        for required in [
23951            "$bundleArgs.Count -ne 0",
23952            "GATE4AGENT_CONTEXT_ROOT",
23953            "context-pack.json",
23954            "$entries.Count -ne 1",
23955            "g4a-context-pack-v1",
23956            "$document.PSObject.Properties['cwd']",
23957            "'claude', 'codex', 'grok', 'kimi'",
23958            "$hasUser",
23959            "$hasAssistant",
23960            "SHA256",
23961            "context-only",
23962        ] {
23963            assert!(script.contains(required), "missing validation: {required}");
23964        }
23965        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23966            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23967        }
23968        assert!(!standard
23969            .get(&kimi)
23970            .unwrap()
23971            .launch
23972            .fixed_args
23973            .iter()
23974            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23975
23976        std::fs::remove_dir_all(root).unwrap();
23977    }
23978
23979    #[cfg(all(feature = "fixture", windows))]
23980    #[tokio::test]
23981    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23982        let root = temporary_workspace_root("kimi-context-only-spawn");
23983        let workspace_root = root.join("workspace");
23984        std::fs::create_dir_all(&workspace_root).unwrap();
23985        let materialization_root = root.join("private-materializations");
23986        let proof_path = root.join("context.proof");
23987        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23988        let workspace_id = WorkspaceId::new("primary").unwrap();
23989        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23990        let endpoint = format!(
23991            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23992            std::process::id(),
23993            unix_time_ms(),
23994        );
23995        let spawn_profiles = SpawnProfileRegistry::new([
23996            crate::protocol::SpawnProfileDefaults {
23997                profile_id: profile_id.clone(),
23998                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23999                provider: agent("kimi"),
24000                mode: SessionMode::Pty,
24001                terminal_size: gate4agent_types::TerminalSize {
24002                    rows: 30,
24003                    columns: 120,
24004                },
24005                prompt: None,
24006                bundle_id: None,
24007                context_id: None,
24008                environment_profile_id: None,
24009            },
24010        ])
24011        .unwrap();
24012        let mut config = NodeServerConfig::new(
24013            &endpoint,
24014            "fixture-token",
24015            node_id.clone(),
24016            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24017        )
24018        .unwrap()
24019        .with_spawn_profiles(spawn_profiles)
24020        .with_session_environment_materialization(
24021            materialization_root,
24022            Arc::new(FixtureSecretResolver {
24023                deny: Arc::new(AtomicBool::new(false)),
24024            }),
24025        )
24026        .unwrap();
24027        config.fixture_raw_pty_runtime = true;
24028        let server = NodeServer::new_context_only_proof_fixture(
24029            config,
24030            agent("kimi"),
24031            proof_path.clone(),
24032        )
24033        .unwrap();
24034        let shared = Arc::clone(&server.shared);
24035        let shutdown = server.shutdown_handle();
24036
24037        let history = HistorySessionRecord {
24038            session_id: "context-only-source-history".to_owned(),
24039            title: None,
24040            cwd: None,
24041            model: None,
24042            message_count: 2,
24043            completed_turn_count: None,
24044            total_tokens: 0,
24045            messages: vec![
24046                gate4agent_types::HistoryMessageRecord {
24047                    role: gate4agent_types::HistoryMessageRole::User,
24048                    text: "continue the bounded parent run through native C2".to_owned(),
24049                },
24050                gate4agent_types::HistoryMessageRecord {
24051                    role: gate4agent_types::HistoryMessageRole::Assistant,
24052                    text: "the exact parent context is ready for the child run".to_owned(),
24053                },
24054            ],
24055        };
24056        let pack = NodeContextPack::export(
24057            ContextPackLineageReceipt {
24058                source_node_id: node_id.clone(),
24059                source_session: SessionAddress {
24060                    workspace_id: workspace_id.clone(),
24061                    session: SessionKey {
24062                        instance_id: AgentInstanceId(71),
24063                        generation: SessionGeneration(1),
24064                    },
24065                },
24066                source_provider: agent("codex"),
24067            },
24068            &history,
24069        )
24070        .unwrap();
24071        let expected_context_hash = digest(&SHA256, pack.bytes())
24072            .as_ref()
24073            .iter()
24074            .map(|byte| format!("{byte:02x}"))
24075            .collect::<String>();
24076        let context = shared
24077            .context_catalog
24078            .write()
24079            .unwrap_or_else(|poisoned| poisoned.into_inner())
24080            .insert(pack)
24081            .unwrap();
24082        let spec = SpawnSpec {
24083            target: crate::protocol::SpawnTarget {
24084                node_id,
24085                workspace_id: workspace_id.clone(),
24086                worktree_id: None,
24087            },
24088            profile_id,
24089            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24090                "target-kimi-r1",
24091            ).unwrap(),
24092            overrides: crate::protocol::SpawnOverrides {
24093                context_id: crate::protocol::SpawnOverride::Set {
24094                    value: context.id.clone(),
24095                },
24096                ..crate::protocol::SpawnOverrides::default()
24097            },
24098            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24099            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24100            required_capabilities: SpawnRequiredCapabilities::default(),
24101        };
24102        let resolved = match shared.resolve_spawn_spec(&spec) {
24103            Ok(resolved) => resolved,
24104            Err(error) => panic!(
24105                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24106                error.code,
24107                error.message,
24108            ),
24109        };
24110        let resolved_context = match shared.resolve_context(&resolved) {
24111            Ok(Some(resolved_context)) => resolved_context,
24112            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24113            Err(error) => panic!(
24114                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24115                error.code,
24116                error.message,
24117            ),
24118        };
24119        assert_eq!(resolved_context, context);
24120        let runtime_policy = match shared
24121            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24122            .await
24123        {
24124            Ok(runtime_policy) => runtime_policy,
24125            Err(error) => panic!(
24126                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24127                error.code,
24128                error.message,
24129            ),
24130        };
24131        assert!(runtime_policy.raw_pty_lifecycle);
24132
24133        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24134        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24135            agent("kimi"),
24136            TransportKind::Pty,
24137            vec![EnvMutation {
24138                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24139                value: Some(OsString::from("must-remain-rejected")),
24140            }],
24141        )
24142        .unwrap();
24143        assert_eq!(
24144            launch_control
24145                .install_native_launch_environment_overlay(
24146                    AgentInstanceId(72),
24147                    generic_overlay,
24148                )
24149                .unwrap_err(),
24150            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24151        );
24152
24153        let diagnostic_address = SessionAddress {
24154            workspace_id: workspace_id.clone(),
24155            session: SessionKey {
24156                instance_id: AgentInstanceId(73),
24157                generation: SessionGeneration(1),
24158            },
24159        };
24160        let (diagnostic_overlay, diagnostic_materialization) = match shared
24161            .prepare_session_materialization(
24162                &diagnostic_address,
24163                &agent("kimi"),
24164                SessionMode::Pty,
24165                None,
24166                None,
24167                Some(&resolved_context),
24168                None,
24169            )
24170        {
24171            Ok(Some(prepared)) => prepared,
24172            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24173            Err(error) => panic!(
24174                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24175                error.code,
24176                error.message,
24177            ),
24178        };
24179        let diagnostic_overlay = diagnostic_overlay
24180            .expect("Kimi context-only materialization omitted its environment overlay");
24181        assert!(matches!(
24182            &diagnostic_overlay,
24183            PreparedNativeLaunchOverlay::Environment(_),
24184        ));
24185        match shared.install_prepared_launch_overlay(
24186            diagnostic_address.session.instance_id,
24187            diagnostic_overlay,
24188        ) {
24189            Ok(None) => {}
24190            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24191            Err(error) => panic!(
24192                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24193                error.code,
24194                error.message,
24195            ),
24196        }
24197        assert!(launch_control.clear_native_instance_launch_overlay(
24198            diagnostic_address.session.instance_id,
24199        ));
24200        drop(diagnostic_materialization);
24201        assert!(shared
24202            .materializations
24203            .lock()
24204            .unwrap_or_else(|poisoned| poisoned.into_inner())
24205            .is_empty());
24206
24207        let node_task = tokio::spawn(server.run());
24208        let ready_client = timeout(Duration::from_secs(5), async {
24209            loop {
24210                match gate4agent_node_wire::LocalNodeClient::connect(
24211                    &endpoint,
24212                    &shared.node_id,
24213                    ClientRole::Observer,
24214                    "fixture-token",
24215                )
24216                .await
24217                {
24218                    Ok(client) => break client,
24219                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24220                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24221                }
24222            }
24223        })
24224        .await
24225        .expect("Kimi context-only Node did not become ready");
24226        assert_eq!(
24227            ready_client
24228                .hello()
24229                .snapshot
24230                .provider_runtime_statuses
24231                .iter()
24232                .find(|status| status.provider().as_str() == "kimi")
24233                .expect("Kimi runtime status is missing")
24234                .mode(),
24235            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24236        );
24237        drop(ready_client);
24238
24239        let receipt = match shared.spawn_from_spec(spec).await {
24240            Ok(receipt) => receipt,
24241            Err(error) => panic!(
24242                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24243                error.code,
24244                error.message,
24245            ),
24246        };
24247        assert_eq!(receipt.provider, agent("kimi"));
24248        assert_eq!(receipt.context.as_ref(), Some(&context));
24249        assert!(receipt.context_binding_is_valid());
24250        timeout(Duration::from_secs(5), async {
24251            while !proof_path.is_file() {
24252                sleep(Duration::from_millis(10)).await;
24253            }
24254        })
24255        .await
24256        .expect("Kimi context-only fixture did not write its proof");
24257
24258        let proof = std::fs::read_to_string(&proof_path).unwrap();
24259        let lines = proof.lines().collect::<Vec<_>>();
24260        assert_eq!(lines.len(), 7);
24261        assert_eq!(lines[0], "context-only");
24262        assert!(Path::new(lines[1]).is_absolute());
24263        assert_ne!(
24264            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24265            std::fs::canonicalize(&workspace_root).unwrap(),
24266        );
24267        assert_eq!(
24268            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24269            std::fs::canonicalize(&workspace_root).unwrap(),
24270        );
24271        assert_eq!(lines[3], expected_context_hash);
24272        assert_eq!(lines[4], "g4a-context-pack-v1");
24273        assert_eq!(lines[5], "codex");
24274        assert_eq!(lines[6], "2");
24275        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24276            session.instance_id == receipt.session.session.instance_id
24277                && session.agent_id.as_str() == "kimi"
24278        }));
24279
24280        shutdown.request_shutdown().await.unwrap();
24281        timeout(Duration::from_secs(5), node_task)
24282            .await
24283            .unwrap()
24284            .unwrap()
24285            .unwrap();
24286        drop(shutdown);
24287        drop(shared);
24288        std::fs::remove_dir_all(root).unwrap();
24289    }
24290
24291    #[cfg(feature = "fixture")]
24292    #[test]
24293    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24294        let root = temporary_workspace_root("clean-exit-constructor");
24295        std::fs::create_dir_all(&root).unwrap();
24296        let config = construction_test_config("clean-exit-constructor")
24297            .with_state_path(root.join("node-state.json"))
24298            .unwrap();
24299        let server = NodeServer::new_clean_exit_fixture(
24300            config,
24301            root.clone(),
24302            root.join("started.marker"),
24303            root.join("release.signal"),
24304        )
24305        .unwrap();
24306        assert_eq!(
24307            server
24308                .shared
24309                .enabled_providers
24310                .iter()
24311                .map(AgentId::as_str)
24312                .collect::<Vec<_>>(),
24313            ["claude"],
24314        );
24315        drop(server);
24316        std::fs::remove_dir_all(root).unwrap();
24317    }
24318
24319    #[test]
24320    fn production_registry_provider_contract_manifest_is_exact() {
24321        let registry = active_registry().unwrap();
24322        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24323        assert_eq!(
24324            providers
24325                .iter()
24326                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24327                .collect::<Vec<_>>(),
24328            vec![
24329                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24330                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24331                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24332                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24333            ]
24334        );
24335        assert_eq!(
24336            adapters
24337                .iter()
24338                .map(|contract| (
24339                    contract.provider.as_str(),
24340                    contract.family,
24341                    contract.adapter_id.as_str(),
24342                    contract.revision.as_str(),
24343                ))
24344                .collect::<Vec<_>>(),
24345            vec![
24346                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24347                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24348                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24349                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24350                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24351                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24352                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24353                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24354                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24355                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24356                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24357                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24358                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24359                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24360                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24361                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24362                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24363                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24364                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24365                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24366                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24367                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24368                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24369            ]
24370        );
24371    }
24372
24373    fn construction_test_config(name: &str) -> NodeServerConfig {
24374        let workspace = WorkspaceConfig::new(
24375            WorkspaceId::new("primary").unwrap(),
24376            std::env::current_dir().unwrap(),
24377        )
24378        .unwrap();
24379        NodeServerConfig::new(
24380            format!(r"\\.\pipe\gate4agent-{name}"),
24381            "fixture-token",
24382            NodeId::new(name).unwrap(),
24383            [workspace],
24384        )
24385        .unwrap()
24386    }
24387
24388    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24389        let adapter = AdapterBinding::new(
24390            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24391            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24392            gate4agent_types::AdapterVerification::SyntheticFixture,
24393        )
24394        .unwrap();
24395        let agent_ids = ["claude", "codex", "kimi"]
24396            .into_iter()
24397            .map(|agent_id| AgentId::new(agent_id).unwrap())
24398            .collect::<Vec<_>>();
24399        let adapters = gate4agent_catalog::AdapterRegistry::new(
24400            [
24401                AdapterFamily::PtySemantic,
24402                AdapterFamily::Pipe,
24403                AdapterFamily::OneShot,
24404                AdapterFamily::Acp,
24405                AdapterFamily::Hook,
24406                AdapterFamily::ManagedHook,
24407                AdapterFamily::History,
24408                AdapterFamily::Resume,
24409                AdapterFamily::SessionOptions,
24410                AdapterFamily::CapabilityProbe,
24411            ]
24412            .into_iter()
24413            .map(|family| gate4agent_catalog::AdapterDescriptor {
24414                family,
24415                binding: adapter.clone(),
24416                agents: agent_ids.clone(),
24417            }),
24418        )
24419        .unwrap();
24420        let specs = agent_ids
24421            .iter()
24422            .map(|agent_id| {
24423                let agent_id = agent_id.as_str();
24424                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24425                spec.revision =
24426                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24427                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24428                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24429                    adapter: adapter.clone(),
24430                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24431                    launch_override: None,
24432                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24433                });
24434                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24435                    adapter: adapter.clone(),
24436                    launch_override: None,
24437                });
24438                spec.capabilities.adapters.hook = Some(adapter.clone());
24439                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24440                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24441                spec.capabilities.adapters.history = Some(adapter.clone());
24442                spec.capabilities.adapters.resume = Some(adapter.clone());
24443                spec.capabilities.adapters.session_options = Some(adapter.clone());
24444                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24445                spec
24446            })
24447            .collect::<Vec<_>>();
24448        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24449    }
24450
24451    /// The real fleet's manifest is accepted by a real server construction.
24452    ///
24453    /// The count is asserted against `active_registry` itself rather than a
24454    /// literal, so this stays a test that construction succeeds for whatever
24455    /// the fleet is -- which is the property worth guarding -- instead of a
24456    /// second place the fleet size is written down and has to be kept in
24457    /// step with the first.
24458    #[test]
24459    fn node_server_construction_accepts_the_production_manifest() {
24460        let registry = active_registry().unwrap();
24461        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24462        assert_eq!(providers.len(), registry.iter().count());
24463        assert!(!providers.is_empty());
24464        let server = NodeServer::new_with_registry(
24465            construction_test_config("manifest-production-bound"),
24466            registry,
24467        )
24468        .unwrap();
24469        drop(server);
24470    }
24471
24472    #[test]
24473    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24474        let registry = maximum_size_provider_manifest_registry();
24475        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24476        assert_eq!(providers.len(), 3);
24477        assert_eq!(adapters.len(), 30);
24478        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24479        let error = match NodeServer::new_with_registry(
24480            construction_test_config("manifest-handshake-overflow"),
24481            registry,
24482        ) {
24483            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24484            Err(error) => error,
24485        };
24486        assert!(matches!(
24487            error,
24488            NodeServerError::ProviderContractManifest(message)
24489                if message.contains("authentication binding length")
24490                    && message.contains("8192-byte limit")
24491        ));
24492    }
24493
24494    #[test]
24495    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24496        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24497        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24498        let registry = AgentRegistry::new([spec]).unwrap();
24499        assert!(matches!(
24500            provider_contract_manifest(&registry),
24501            Err(NodeServerError::ProviderContractManifest(message))
24502                if message.contains("exceeds the 128-byte limit")
24503        ));
24504    }
24505
24506    #[test]
24507    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24508        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24509        spec.revision = "invalid revision".to_owned();
24510        let registry = AgentRegistry::new([spec]).unwrap();
24511        assert!(matches!(
24512            provider_contract_manifest(&registry),
24513            Err(NodeServerError::ProviderContractManifest(message))
24514                if message.contains("bounded lowercase ASCII")
24515        ));
24516    }
24517
24518    #[test]
24519    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24520        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24521        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24522        let conflicting_binding = builtin_registry()
24523            .get_by_id("codex")
24524            .unwrap()
24525            .capabilities
24526            .adapters
24527            .one_shot
24528            .clone()
24529            .unwrap();
24530        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24531        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24532        pipe.adapter = transport_binding;
24533        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24534        let registry = AgentRegistry::new([spec]).unwrap();
24535        assert!(matches!(
24536            provider_contract_manifest(&registry),
24537            Err(NodeServerError::ProviderContractManifest(message))
24538                if message.contains("conflicting OneShot adapter bindings")
24539        ));
24540    }
24541
24542    #[test]
24543    fn public_node_failures_expose_only_fixed_categories() {
24544        let provider = failure(
24545            NodeFailureCode::BackendOperationFailed,
24546            r"provider bearer-secret failed at C:\private\session.jsonl",
24547        );
24548        assert_eq!(provider.message, "backend-operation-failed");
24549        assert!(!provider.message.contains("bearer-secret"));
24550        assert!(!provider.message.contains(r"C:\private"));
24551        let persistence = persistence_failure(io::Error::new(
24552            io::ErrorKind::Other,
24553            r"state write failed at C:\private\state-v1.json",
24554        ));
24555        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24556        assert!(!persistence.message.contains(r"C:\private"));
24557
24558        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24559            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24560            assert_eq!(
24561                generic.to_string(),
24562                "node durable state failed: durable-state-load-failed",
24563            );
24564        }
24565    }
24566
24567    #[test]
24568    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24569        let sequence = b"\x1b[1;5D".to_vec();
24570        assert_eq!(
24571            terminal_bytes_action(sequence.clone()).unwrap(),
24572            InputAction::TerminalBytes(sequence),
24573        );
24574        assert_eq!(
24575            terminal_bytes_action(Vec::new()).unwrap_err().code,
24576            NodeFailureCode::InvalidRequest,
24577        );
24578        assert_eq!(
24579            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24580                .unwrap_err()
24581                .code,
24582            NodeFailureCode::InvalidRequest,
24583        );
24584    }
24585
24586    #[test]
24587    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24588        let root = temporary_workspace_root("exclusive-state-owner");
24589        std::fs::create_dir_all(&root).unwrap();
24590        let workspace = WorkspaceConfig::new(
24591            WorkspaceId::new("primary").unwrap(),
24592            &root,
24593        )
24594        .unwrap();
24595        let state_path = root.join("state-v1.json");
24596        let endpoint = format!(
24597            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24598            std::process::id(),
24599            std::time::SystemTime::now()
24600                .duration_since(std::time::UNIX_EPOCH)
24601                .unwrap()
24602                .as_nanos(),
24603        );
24604        let config = NodeServerConfig::new(
24605            endpoint,
24606            "fixture-token",
24607            NodeId::new("node-1").unwrap(),
24608            [workspace],
24609        )
24610        .unwrap()
24611        .with_state_path(&state_path)
24612        .unwrap();
24613
24614        let first = NodeServer::new(config.clone()).unwrap();
24615        let error = match NodeServer::new(config.clone()) {
24616            Ok(_) => panic!("a second node server acquired the same durable state path"),
24617            Err(error) => error,
24618        };
24619        assert!(matches!(
24620            &error,
24621            NodeServerError::DurableState(error)
24622                if error.kind() == io::ErrorKind::WouldBlock
24623        ));
24624        assert_eq!(
24625            error.to_string(),
24626            "node durable state failed: durable-state-lock-failed",
24627        );
24628        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24629        drop(first);
24630        let second = NodeServer::new(config).unwrap();
24631        drop(second);
24632        std::fs::remove_dir_all(root).unwrap();
24633    }
24634
24635    #[test]
24636    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24637        let catalog = active_registry().unwrap();
24638        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24639        let workspace_id = WorkspaceId::new("primary").unwrap();
24640        let workspace = WorkspaceConfig::new(
24641            workspace_id.clone(),
24642            std::env::current_dir().unwrap(),
24643        )
24644        .unwrap();
24645        let shared = NodeShared::new(
24646            handle,
24647            "fixture-token".to_owned(),
24648            NodeId::new("node-1").unwrap(),
24649            vec![workspace.clone()],
24650            vec![agent("claude"), agent("codex"), agent("kimi")],
24651        );
24652        let instance_id = AgentInstanceId(17);
24653        let generation = SessionGeneration(3);
24654        assert!(shared.address_for(instance_id, generation).is_none());
24655        shared.bind_session(instance_id, workspace_id.clone(), generation);
24656        let address = SessionAddress {
24657            workspace_id,
24658            session: SessionKey { instance_id, generation },
24659        };
24660        assert_eq!(
24661            shared.workspace_root(&address.workspace_id).unwrap(),
24662            workspace.canonical_root,
24663        );
24664        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24665        shared.remove_binding(&address);
24666        assert!(shared.address_for(instance_id, generation).is_none());
24667    }
24668
24669    #[test]
24670    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24671        let address = SessionAddress {
24672            workspace_id: WorkspaceId::new("primary").unwrap(),
24673            session: SessionKey {
24674                instance_id: AgentInstanceId(7),
24675                generation: SessionGeneration(2),
24676            },
24677        };
24678        let record_id = SessionRecordId::new("record-a").unwrap();
24679        let size = gate4agent_types::TerminalSize {
24680            rows: 24,
24681            columns: 80,
24682        };
24683        let address_requests = [
24684            NodeRequest::Resume {
24685                session: address.clone(),
24686                terminal_size: size,
24687                initial_prompt: None,
24688            },
24689            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24690            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24691            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24692            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24693            NodeRequest::TerminalControl {
24694                session: address.clone(),
24695                control: TerminalControl::Enter,
24696            },
24697            NodeRequest::Resize { session: address.clone(), size },
24698            NodeRequest::Interrupt { session: address.clone() },
24699            NodeRequest::Stop { session: address.clone(), force: false },
24700            NodeRequest::Remove { session: address },
24701        ];
24702        for request in &address_requests {
24703            assert!(request_requires_open_provider_ids_with(
24704                request,
24705                |_| Some(agent("third-party-agent")),
24706                |_| Some(agent("claude")),
24707            ));
24708            assert!(!request_requires_open_provider_ids_with(
24709                request,
24710                |_| Some(agent("claude")),
24711                |_| Some(agent("claude")),
24712            ));
24713            assert!(request_requires_open_provider_ids_with(
24714                request,
24715                |_| None,
24716                |_| Some(agent("claude")),
24717            ));
24718        }
24719
24720        let record_requests = [
24721            NodeRequest::RenameSessionRecord {
24722                record_id: record_id.clone(),
24723                display_name: "renamed".to_owned(),
24724            },
24725            NodeRequest::ResumeSessionRecord {
24726                record_id: record_id.clone(),
24727                terminal_size: size,
24728                initial_prompt: None,
24729            },
24730            NodeRequest::ForgetSessionRecord { record_id },
24731        ];
24732        for request in &record_requests {
24733            assert!(request_requires_open_provider_ids_with(
24734                request,
24735                |_| Some(agent("claude")),
24736                |_| Some(agent("grok")),
24737            ));
24738            assert!(!request_requires_open_provider_ids_with(
24739                request,
24740                |_| Some(agent("claude")),
24741                |_| Some(agent("kimi")),
24742            ));
24743            assert!(request_requires_open_provider_ids_with(
24744                request,
24745                |_| Some(agent("claude")),
24746                |_| None,
24747            ));
24748        }
24749
24750        assert!(request_requires_open_provider_ids_with(
24751            &NodeRequest::Spawn {
24752                workspace_id: WorkspaceId::new("primary").unwrap(),
24753                provider: agent("third-party-agent"),
24754                mode: SessionMode::Pty,
24755                terminal_size: size,
24756                initial_prompt: None,
24757            },
24758            |_| None,
24759            |_| None,
24760        ));
24761        assert!(!request_requires_open_provider_ids_with(
24762            &NodeRequest::Snapshot,
24763            |_| None,
24764            |_| None,
24765        ));
24766        assert!(request_requires_open_provider_ids_with(
24767            &NodeRequest::ForgetContextPack {
24768                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24769            },
24770            |_| None,
24771            |_| None,
24772        ));
24773    }
24774
24775    #[test]
24776    fn legacy_projection_filters_open_record_replies_and_event_history() {
24777        let catalog = active_registry().unwrap();
24778        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24779        let workspace = WorkspaceConfig::new(
24780            WorkspaceId::new("primary").unwrap(),
24781            std::env::current_dir().unwrap(),
24782        )
24783        .unwrap();
24784        let shared = NodeShared::new(
24785            handle,
24786            "fixture-token".to_owned(),
24787            NodeId::new("node-1").unwrap(),
24788            vec![workspace],
24789            vec![agent("claude"), agent("grok")],
24790        );
24791        let legacy = record("claude", "legacy-record");
24792        let open = record("grok", "open-record");
24793        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24794            record: legacy.clone(),
24795        });
24796        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24797            record: open.clone(),
24798        });
24799        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24800            record_id: legacy.record_id.clone(),
24801        });
24802        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24803            record_id: open.record_id.clone(),
24804        });
24805        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24806        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24807        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24808        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24809
24810        let mut record_reply = ResponseEnvelope {
24811            request_id: 1,
24812            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24813        };
24814        project_response_legacy_provider_ids(&shared, &mut record_reply);
24815        assert!(matches!(
24816            record_reply.result,
24817            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24818        ));
24819
24820        let mut resync = ResponseEnvelope {
24821            request_id: 2,
24822            result: Ok(NodeResponse::Resync {
24823                event_sequence: open_removed.sequence,
24824                oldest_available_sequence: 1,
24825                snapshot: shared.snapshot(),
24826                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24827            }),
24828        };
24829        project_response_legacy_provider_ids(&shared, &mut resync);
24830        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24831            panic!("legacy projection changed the resync response kind");
24832        };
24833        assert!(snapshot.session_records.iter().all(|item| {
24834            provider_id_is_legacy(&item.provider)
24835        }));
24836        assert_eq!(events.len(), 2);
24837        assert!(events.iter().all(|event| matches!(
24838            &event.event,
24839            NodeEvent::SessionRecordUpserted { record }
24840                if provider_id_is_legacy(&record.provider)
24841        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24842    }
24843
24844    #[test]
24845    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24846        let catalog = active_registry().unwrap();
24847        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24848        let workspace_id = WorkspaceId::new("primary").unwrap();
24849        let workspace = WorkspaceConfig::new(
24850            workspace_id.clone(),
24851            std::env::current_dir().unwrap(),
24852        )
24853        .unwrap();
24854        let shared = NodeShared::new(
24855            handle,
24856            "fixture-token".to_owned(),
24857            NodeId::new("node-1").unwrap(),
24858            vec![workspace],
24859            vec![agent("claude")],
24860        );
24861        for index in 0..CONTROL_SESSIONS_MAX {
24862            shared.bind_session(
24863                AgentInstanceId((index + 1) as u64),
24864                workspace_id.clone(),
24865                SessionGeneration(1),
24866            );
24867        }
24868        let error = shared.ensure_binding_capacity().unwrap_err();
24869        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24870    }
24871
24872    #[tokio::test]
24873    async fn provider_spawn_admission_rejects_before_session_mutation() {
24874        let catalog = active_registry().unwrap();
24875        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24876        let workspace = WorkspaceConfig::new(
24877            WorkspaceId::new("primary").unwrap(),
24878            std::env::current_dir().unwrap(),
24879        )
24880        .unwrap();
24881        let mut shared = NodeShared::new(
24882            handle,
24883            "fixture-token".to_owned(),
24884            NodeId::new("node-provider-admission").unwrap(),
24885            vec![workspace],
24886            vec![agent("claude")],
24887        );
24888        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24889            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24890        ])
24891        .unwrap();
24892        let terminal_size = gate4agent_types::TerminalSize {
24893            rows: 24,
24894            columns: 80,
24895        };
24896
24897        let unavailable = shared
24898            .spawn_session(
24899                WorkspaceId::new("primary").unwrap(),
24900                agent("claude"),
24901                SessionMode::Pty,
24902                terminal_size,
24903                None,
24904            )
24905            .await
24906            .unwrap_err();
24907        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24908        assert_eq!(unavailable.message, "backend-operation-failed");
24909
24910        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24911            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24912                agent("claude"),
24913                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24914            ),
24915        ])
24916        .unwrap();
24917        let semantic = shared
24918            .spawn_session(
24919                WorkspaceId::new("primary").unwrap(),
24920                agent("claude"),
24921                SessionMode::Inline,
24922                terminal_size,
24923                None,
24924            )
24925            .await
24926            .unwrap_err();
24927        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24928        assert_eq!(semantic.message, "unsupported-capability");
24929
24930        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24931        assert!(shared
24932            .session_bindings
24933            .lock()
24934            .unwrap_or_else(|poisoned| poisoned.into_inner())
24935            .is_empty());
24936        assert!(shared
24937            .session_records
24938            .lock()
24939            .unwrap_or_else(|poisoned| poisoned.into_inner())
24940            .records
24941            .is_empty());
24942        assert!(shared.handle.snapshot().sessions.is_empty());
24943    }
24944
24945    #[tokio::test]
24946    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24947        let catalog = active_registry().unwrap();
24948        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24949        let workspace_id = WorkspaceId::new("primary").unwrap();
24950        let workspace = WorkspaceConfig::new(
24951            workspace_id.clone(),
24952            std::env::current_dir().unwrap(),
24953        )
24954        .unwrap();
24955        let shared = NodeShared::new(
24956            handle,
24957            "fixture-token".to_owned(),
24958            NodeId::new("node-raw-spawn-record").unwrap(),
24959            vec![workspace],
24960            vec![agent("claude")],
24961        );
24962        let address = SessionAddress {
24963            workspace_id,
24964            session: SessionKey {
24965                instance_id: AgentInstanceId(71),
24966                generation: SessionGeneration::default(),
24967            },
24968        };
24969
24970        let record_id = shared
24971            .bind_spawn_session(
24972                &address,
24973                agent("claude"),
24974                SessionMode::Pty,
24975                ProviderRuntimePolicy::raw_pty(),
24976                SpawnRecordPolicy::Always,
24977                None,
24978            )
24979            .unwrap()
24980            .unwrap();
24981        shared
24982            .handle
24983            .dispatch(shared.prepare_command(ControlCommand::Register {
24984                instance_id: address.session.instance_id,
24985                agent_id: agent("claude"),
24986                transport: TransportKind::Pty,
24987            }))
24988            .unwrap();
24989        runtime.tick().await;
24990
24991        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24992            session.instance_id == address.session.instance_id
24993                && session.generation == address.session.generation
24994        }));
24995        let record = shared.record(&record_id).unwrap();
24996        assert_eq!(record.display_name, "claude #1");
24997        assert_eq!(record.state, ManagedSessionState::Live);
24998        assert_eq!(record.active_session.as_ref(), Some(&address));
24999        assert!(record.provider_session.is_none());
25000        let binding_record_id = shared
25001            .session_bindings
25002            .lock()
25003            .unwrap_or_else(|poisoned| poisoned.into_inner())
25004            .get(&address.session.instance_id)
25005            .and_then(|binding| binding.record_id.clone());
25006        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25007
25008        let renamed = shared
25009            .rename_session_record(&record_id, "raw review".to_owned())
25010            .unwrap();
25011        assert_eq!(renamed.record_id, record_id);
25012        assert_eq!(renamed.display_name, "raw review");
25013        shared.reconcile_managed_record(
25014            &record_id,
25015            &address,
25016            &ControlEvent {
25017                sequence: 1,
25018                command_id: None,
25019                instance_id: address.session.instance_id,
25020                generation: address.session.generation,
25021                event: ControlEventKind::Running { process_id: None },
25022            },
25023            false,
25024            false,
25025        );
25026        let running = shared.record(&record_id).unwrap();
25027        assert_eq!(running.state, ManagedSessionState::Live);
25028        assert_eq!(running.active_session.as_ref(), Some(&address));
25029        assert_eq!(running.display_name, "raw review");
25030
25031        shared.reconcile_managed_record(
25032            &record_id,
25033            &address,
25034            &ControlEvent {
25035                sequence: 2,
25036                command_id: None,
25037                instance_id: address.session.instance_id,
25038                generation: address.session.generation,
25039                event: ControlEventKind::Exited {
25040                    exit_code: Some(0),
25041                    forced: false,
25042                },
25043            },
25044            false,
25045            false,
25046        );
25047        let detached = shared.record(&record_id).unwrap();
25048        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25049        assert!(detached.active_session.is_none());
25050        assert_eq!(detached.display_name, "raw review");
25051        let renamed_detached = shared
25052            .rename_session_record(&record_id, "raw review detached".to_owned())
25053            .unwrap();
25054        assert_eq!(renamed_detached.record_id, record_id);
25055        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25056        assert!(renamed_detached.active_session.is_none());
25057        assert_eq!(renamed_detached.display_name, "raw review detached");
25058        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25059    }
25060
25061    #[test]
25062    fn legacy_raw_spawn_binding_remains_untracked() {
25063        let catalog = active_registry().unwrap();
25064        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25065        let workspace_id = WorkspaceId::new("primary").unwrap();
25066        let workspace = WorkspaceConfig::new(
25067            workspace_id.clone(),
25068            std::env::current_dir().unwrap(),
25069        )
25070        .unwrap();
25071        let shared = NodeShared::new(
25072            handle,
25073            "fixture-token".to_owned(),
25074            NodeId::new("node-legacy-raw-spawn").unwrap(),
25075            vec![workspace],
25076            vec![agent("claude")],
25077        );
25078        let address = SessionAddress {
25079            workspace_id,
25080            session: SessionKey {
25081                instance_id: AgentInstanceId(72),
25082                generation: SessionGeneration::default(),
25083            },
25084        };
25085
25086        let record_id = shared
25087            .bind_spawn_session(
25088                &address,
25089                agent("claude"),
25090                SessionMode::Pty,
25091                ProviderRuntimePolicy::raw_pty(),
25092                SpawnRecordPolicy::ProviderIdentityOnly,
25093                None,
25094            )
25095            .unwrap();
25096
25097        assert!(record_id.is_none());
25098        let binding_record_id = shared
25099            .session_bindings
25100            .lock()
25101            .unwrap_or_else(|poisoned| poisoned.into_inner())
25102            .get(&address.session.instance_id)
25103            .and_then(|binding| binding.record_id.clone());
25104        assert!(binding_record_id.is_none());
25105        assert!(shared.snapshot().session_records.is_empty());
25106    }
25107
25108    #[tokio::test]
25109    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25110        let catalog = active_registry().unwrap();
25111        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25112        let workspace_id = WorkspaceId::new("primary").unwrap();
25113        let workspace = WorkspaceConfig::new(
25114            workspace_id.clone(),
25115            std::env::current_dir().unwrap(),
25116        )
25117        .unwrap();
25118        let mut shared = NodeShared::new(
25119            handle,
25120            "fixture-token".to_owned(),
25121            NodeId::new("node-raw-semantic-gate").unwrap(),
25122            vec![workspace],
25123            vec![agent("claude")],
25124        );
25125        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25126            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25127        ])
25128        .unwrap();
25129        shared
25130            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25131            .unwrap();
25132        let address = SessionAddress {
25133            workspace_id,
25134            session: SessionKey {
25135                instance_id: AgentInstanceId(72),
25136                generation: SessionGeneration::default(),
25137            },
25138        };
25139        shared.bind_session_with_policy(
25140            &address,
25141            ProviderRuntimePolicy::raw_pty(),
25142            None,
25143        );
25144        shared
25145            .handle
25146            .dispatch(shared.prepare_command(ControlCommand::Register {
25147                instance_id: address.session.instance_id,
25148                agent_id: agent("claude"),
25149                transport: TransportKind::Pty,
25150            }))
25151            .unwrap();
25152        runtime.tick().await;
25153        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25154        let revision_before = shared.handle.snapshot().revision;
25155        let history_sequence_before = shared.current_sequence();
25156        let terminal_size = gate4agent_types::TerminalSize {
25157            rows: 24,
25158            columns: 80,
25159        };
25160
25161        for request in [
25162            NodeRequest::Prompt {
25163                session: address.clone(),
25164                text: "prompt".to_owned(),
25165            },
25166            NodeRequest::Paste {
25167                session: address.clone(),
25168                text: "paste".to_owned(),
25169            },
25170            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25171            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25172            // (promptless) resume is a raw PTY relaunch and IS admitted under
25173            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25174            // in `provider_runtime.rs`; it is deliberately not exercised here.
25175            NodeRequest::Resume {
25176                session: address.clone(),
25177                terminal_size,
25178                initial_prompt: Some("resume".to_owned()),
25179            },
25180        ] {
25181            let error = process_request_inner(
25182                &shared,
25183                77,
25184                ClientRole::Operator,
25185                request,
25186            )
25187            .await
25188            .unwrap_err();
25189            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25190        }
25191
25192        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25193        assert_eq!(shared.handle.snapshot().revision, revision_before);
25194        assert_eq!(shared.current_sequence(), history_sequence_before);
25195        let bindings = shared
25196            .session_bindings
25197            .lock()
25198            .unwrap_or_else(|poisoned| poisoned.into_inner());
25199        let binding = bindings.get(&address.session.instance_id).unwrap();
25200        assert_eq!(binding.generation, address.session.generation);
25201        assert!(binding.pending_resume.is_none());
25202        assert!(binding.record_id.is_none());
25203    }
25204
25205    #[tokio::test]
25206    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25207        let catalog = active_registry().unwrap();
25208        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25209        let primary = WorkspaceConfig::new(
25210            WorkspaceId::new("primary").unwrap(),
25211            std::env::current_dir().unwrap(),
25212        )
25213        .unwrap();
25214        let shared = NodeShared::new(
25215            handle,
25216            "fixture-token".to_owned(),
25217            NodeId::new("node-1").unwrap(),
25218            vec![primary],
25219            vec![agent("claude")],
25220        );
25221        shared
25222            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25223            .unwrap();
25224        let before = shared.snapshot();
25225        let error = process_request_inner(
25226            &shared,
25227            77,
25228            ClientRole::Operator,
25229            NodeRequest::RegisterWorkspace {
25230                workspace_id: WorkspaceId::new("foreign").unwrap(),
25231                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25232            },
25233        )
25234        .await
25235        .unwrap_err();
25236        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25237        assert_eq!(error.message, "invalid-request");
25238        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25239    }
25240
25241    #[tokio::test]
25242    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25243        let catalog = active_registry().unwrap();
25244        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25245        let primary_id = WorkspaceId::new("primary").unwrap();
25246        let primary = WorkspaceConfig::new(
25247            primary_id.clone(),
25248            std::env::current_dir().unwrap(),
25249        )
25250        .unwrap();
25251        let shared = NodeShared::new(
25252            handle,
25253            "fixture-token".to_owned(),
25254            NodeId::new("node-1").unwrap(),
25255            vec![primary.clone()],
25256            vec![agent("claude")],
25257        );
25258        let secondary_id = WorkspaceId::new("secondary").unwrap();
25259        let secondary = shared
25260            .register_workspace(
25261                secondary_id.clone(),
25262                std::env::temp_dir().to_string_lossy().into_owned(),
25263            )
25264            .await
25265            .unwrap();
25266        assert_eq!(secondary.workspace_id, secondary_id);
25267        assert!(secondary.sessions.is_empty());
25268        assert_eq!(shared.snapshot().workspaces.len(), 2);
25269
25270        let duplicate_id = shared
25271            .register_workspace(
25272                secondary_id.clone(),
25273                primary.canonical_root().to_owned(),
25274            )
25275            .await
25276            .unwrap_err();
25277        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25278        let duplicate_root = shared
25279            .register_workspace(
25280                WorkspaceId::new("duplicate-root").unwrap(),
25281                windows_path_text(&secondary.canonical_root).to_owned(),
25282            )
25283            .await
25284            .unwrap_err();
25285        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25286
25287        let address = SessionAddress {
25288            workspace_id: secondary_id.clone(),
25289            session: SessionKey {
25290                instance_id: AgentInstanceId(41),
25291                generation: SessionGeneration(1),
25292            },
25293        };
25294        shared.bind_session(
25295            address.session.instance_id,
25296            secondary_id.clone(),
25297            address.session.generation,
25298        );
25299        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25300        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25301        shared.remove_binding(&address);
25302        shared.unregister_workspace(&secondary_id).unwrap();
25303        assert_eq!(shared.snapshot().workspaces.len(), 1);
25304
25305        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25306        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25307        let history = shared
25308            .history
25309            .lock()
25310            .unwrap_or_else(|poisoned| poisoned.into_inner());
25311        assert!(matches!(
25312            &history.events[0],
25313            NodeEventEnvelope {
25314                sequence: 1,
25315                event: NodeEvent::WorkspaceAdded { workspace },
25316            } if workspace.workspace_id == secondary_id
25317        ));
25318        assert!(matches!(
25319            &history.events[1],
25320            NodeEventEnvelope {
25321                sequence: 2,
25322                event: NodeEvent::WorkspaceRemoved { workspace_id },
25323            } if workspace_id == &secondary_id
25324        ));
25325    }
25326
25327    #[tokio::test]
25328    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25329        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25330        std::fs::create_dir_all(&secondary_root).unwrap();
25331        let catalog = active_registry().unwrap();
25332        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25333        let primary_id = WorkspaceId::new("primary").unwrap();
25334        let secondary_id = WorkspaceId::new("secondary").unwrap();
25335        let shared = NodeShared::new(
25336            handle,
25337            "fixture-token".to_owned(),
25338            NodeId::new("node-session-reference-index").unwrap(),
25339            vec![
25340                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25341                    .unwrap(),
25342                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25343            ],
25344            vec![agent("claude"), agent("codex")],
25345        );
25346        let identity = ProviderSessionIdentity {
25347            key: gate4agent_types::ProviderSessionKey::SessionId,
25348            id: "provider-session-42".to_owned(),
25349            transcript_path: None,
25350        };
25351        let index_request = |workspace_id: WorkspaceId,
25352                             provider: AgentId,
25353                             identity: ProviderSessionIdentity,
25354                             display_name: &str| {
25355            NodeRequest::IndexProviderSession {
25356                workspace_id,
25357                provider,
25358                identity,
25359                display_name: display_name.to_owned(),
25360            }
25361        };
25362        let request = index_request(
25363            primary_id.clone(),
25364            agent("claude"),
25365            identity.clone(),
25366            "release shepherd",
25367        );
25368        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25369            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25370        }));
25371        let observer = process_request_inner(
25372            &shared,
25373            77,
25374            ClientRole::Observer,
25375            request.clone(),
25376        )
25377        .await
25378        .unwrap_err();
25379        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25380        let uncontrolled = process_request_inner(
25381            &shared,
25382            77,
25383            ClientRole::Operator,
25384            request.clone(),
25385        )
25386        .await
25387        .unwrap_err();
25388        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25389        shared
25390            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25391            .unwrap();
25392
25393        let indexed = process_request_inner(
25394            &shared,
25395            77,
25396            ClientRole::Operator,
25397            request.clone(),
25398        )
25399        .await
25400        .unwrap();
25401        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25402            panic!("provider session reference index returned another response");
25403        };
25404        assert_eq!(record.state, ManagedSessionState::Dormant);
25405        assert_eq!(record.mode, SessionMode::Pty);
25406        assert_eq!(record.workspace_id, primary_id);
25407        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25408        assert!(record.active_session.is_none());
25409        assert!(record.environment_profile.is_none());
25410        assert!(record.bundle.is_none());
25411        assert!(record.context.is_none());
25412
25413        let repeated = process_request_inner(
25414            &shared,
25415            77,
25416            ClientRole::Operator,
25417            index_request(
25418                primary_id.clone(),
25419                agent("claude"),
25420                identity.clone(),
25421                "ignored replacement",
25422            ),
25423        )
25424        .await
25425        .unwrap();
25426        assert!(matches!(
25427            repeated,
25428            NodeResponse::ProviderSessionIndexed { record: ref existing }
25429                if existing.record_id == record.record_id
25430                    && existing.display_name == "release shepherd"
25431        ));
25432        let conflicting_workspace = process_request_inner(
25433            &shared,
25434            77,
25435            ClientRole::Operator,
25436            index_request(
25437                secondary_id,
25438                agent("claude"),
25439                identity.clone(),
25440                "release shepherd",
25441            ),
25442        )
25443        .await
25444        .unwrap_err();
25445        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25446        let conflicting_provider = process_request_inner(
25447            &shared,
25448            77,
25449            ClientRole::Operator,
25450            index_request(
25451                primary_id.clone(),
25452                agent("codex"),
25453                identity.clone(),
25454                "release shepherd",
25455            ),
25456        )
25457        .await
25458        .unwrap_err();
25459        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25460        let transcript_rejected = process_request_inner(
25461            &shared,
25462            77,
25463            ClientRole::Operator,
25464            index_request(
25465                primary_id,
25466                agent("claude"),
25467                ProviderSessionIdentity {
25468                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25469                    ..identity
25470                },
25471                "release shepherd",
25472            ),
25473        )
25474        .await
25475        .unwrap_err();
25476        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25477        let upserts = shared
25478            .history
25479            .lock()
25480            .unwrap_or_else(|poisoned| poisoned.into_inner())
25481            .events
25482            .iter()
25483            .filter(|event| matches!(
25484                &event.event,
25485                NodeEvent::SessionRecordUpserted { record: published }
25486                    if published.record_id == record.record_id
25487            ))
25488            .count();
25489        assert_eq!(upserts, 1);
25490        std::fs::remove_dir_all(secondary_root).unwrap();
25491    }
25492
25493    #[tokio::test]
25494    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25495        let fixture_root = temporary_workspace_root("standalone-independent");
25496        let target = fixture_root.join("independent");
25497        std::fs::create_dir_all(&fixture_root).unwrap();
25498        let catalog = active_registry().unwrap();
25499        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25500        let selected = WorkspaceConfig::new(
25501            WorkspaceId::new("primary").unwrap(),
25502            std::env::current_dir().unwrap(),
25503        )
25504        .unwrap();
25505        let selected_root = selected.canonical_root().to_owned();
25506        let shared = NodeShared::new(
25507            handle,
25508            "fixture-token".to_owned(),
25509            NodeId::new("node-standalone").unwrap(),
25510            vec![selected],
25511            vec![agent("claude")],
25512        );
25513        let workspace_id = WorkspaceId::new("independent").unwrap();
25514        let request = NodeRequest::CreateStandaloneWorkspace {
25515            workspace_id: workspace_id.clone(),
25516            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25517            initial_branch: Some("main".to_owned()),
25518        };
25519        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25520            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25521        }));
25522        assert!(!request_uses_unnegotiated_capability(
25523            &request,
25524            &baseline_capabilities().unwrap(),
25525        ));
25526        assert!(request_uses_unnegotiated_capability(&request, &[]));
25527
25528        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25529            .await
25530            .unwrap_err();
25531        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25532        assert!(!target.exists());
25533        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25534            .await
25535            .unwrap_err();
25536        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25537        assert!(!target.exists());
25538        shared
25539            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25540            .unwrap();
25541
25542        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25543            .await
25544            .unwrap();
25545        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25546            panic!("standalone workspace creation returned another response");
25547        };
25548        assert_eq!(workspace.workspace_id, workspace_id);
25549        assert!(target.join(".git").is_dir());
25550        let branch = run_git_bounded(
25551            target.to_str().unwrap(),
25552            &["branch", "--show-current"],
25553            GIT_OUTPUT_MAX_BYTES,
25554        )
25555        .await
25556        .unwrap();
25557        assert!(branch.success);
25558        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25559        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25560        assert_eq!(worktrees.len(), 1);
25561        let listed_root = WorkspaceConfig::new(
25562            WorkspaceId::new("listed-independent").unwrap(),
25563            &worktrees[0].path,
25564        )
25565        .unwrap();
25566        assert!(platform::roots_equal(
25567            listed_root.canonical_root(),
25568            windows_path_text(&workspace.canonical_root),
25569        ));
25570        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25571        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25572            snapshot.workspace_id == workspace_id
25573                && snapshot.canonical_root == workspace.canonical_root
25574        }));
25575        assert!(shared
25576            .history
25577            .lock()
25578            .unwrap_or_else(|poisoned| poisoned.into_inner())
25579            .events
25580            .iter()
25581            .any(|event| matches!(
25582                &event.event,
25583                NodeEvent::WorkspaceAdded { workspace }
25584                    if workspace.workspace_id == workspace_id
25585            )));
25586        std::fs::remove_dir_all(fixture_root).unwrap();
25587    }
25588
25589    #[tokio::test]
25590    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25591        let fixture_root = temporary_workspace_root("standalone-compensation");
25592        let created_target = fixture_root.join("created-target");
25593        let existing_target = fixture_root.join("existing-target");
25594        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25595        let invalid_state_path = invalid_state_parent.join("state.json");
25596        std::fs::create_dir_all(&existing_target).unwrap();
25597        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25598        let catalog = active_registry().unwrap();
25599        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25600        let primary = WorkspaceConfig::new(
25601            WorkspaceId::new("primary").unwrap(),
25602            std::env::current_dir().unwrap(),
25603        )
25604        .unwrap();
25605        let mut shared = NodeShared::new(
25606            handle,
25607            "fixture-token".to_owned(),
25608            NodeId::new("node-standalone-compensation").unwrap(),
25609            vec![primary],
25610            vec![agent("claude")],
25611        );
25612        shared.state_path = Some(invalid_state_path);
25613
25614        let created_recovery = shared
25615            .create_standalone_workspace(
25616                WorkspaceId::new("compensated").unwrap(),
25617                created_target.to_string_lossy().into_owned(),
25618                Some("main".to_owned()),
25619            )
25620            .await
25621            .unwrap_err();
25622        assert_eq!(
25623            created_recovery.code,
25624            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25625        );
25626        assert!(created_target.join(".git").is_dir());
25627        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25628            workspace.workspace_id.as_str() != "compensated"
25629        }));
25630
25631        let existing_recovery = shared
25632            .create_standalone_workspace(
25633                WorkspaceId::new("restored").unwrap(),
25634                existing_target.to_string_lossy().into_owned(),
25635                None,
25636            )
25637            .await
25638            .unwrap_err();
25639        assert_eq!(
25640            existing_recovery.code,
25641            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25642        );
25643        assert!(existing_target.is_dir());
25644        assert!(existing_target.join(".git").is_dir());
25645        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25646            workspace.workspace_id.as_str() != "restored"
25647        }));
25648
25649        let registered_empty = fixture_root.join("registered-empty");
25650        std::fs::create_dir(&registered_empty).unwrap();
25651        let catalog = active_registry().unwrap();
25652        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25653        let registered = WorkspaceConfig::new(
25654            WorkspaceId::new("registered-empty").unwrap(),
25655            &registered_empty,
25656        )
25657        .unwrap();
25658        let duplicate_shared = NodeShared::new(
25659            handle,
25660            "fixture-token".to_owned(),
25661            NodeId::new("node-standalone-duplicate-root").unwrap(),
25662            vec![registered],
25663            vec![agent("claude")],
25664        );
25665        let duplicate = duplicate_shared
25666            .create_standalone_workspace(
25667                WorkspaceId::new("duplicate-root").unwrap(),
25668                registered_empty.to_string_lossy().into_owned(),
25669                None,
25670            )
25671            .await
25672            .unwrap_err();
25673        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25674        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25675        std::fs::remove_dir_all(fixture_root).unwrap();
25676    }
25677
25678    #[test]
25679    fn managed_record_id_collision_preserves_the_existing_record() {
25680        let catalog = active_registry().unwrap();
25681        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25682        let workspace = WorkspaceConfig::new(
25683            WorkspaceId::new("primary").unwrap(),
25684            std::env::current_dir().unwrap(),
25685        )
25686        .unwrap();
25687        let shared = NodeShared::new(
25688            handle,
25689            "fixture-token".to_owned(),
25690            NodeId::new("node-1").unwrap(),
25691            vec![workspace.clone()],
25692            vec![agent("claude")],
25693        );
25694        let record_id = SessionRecordId::new("sr-collision").unwrap();
25695        let original = ManagedSessionRecord {
25696            record_id: record_id.clone(),
25697            display_name: "original".to_owned(),
25698            provider: agent("claude"),
25699            mode: SessionMode::Pty,
25700            state: ManagedSessionState::Dormant,
25701            workspace_id: workspace.workspace_id,
25702            canonical_root: opaque_windows_path(workspace.canonical_root),
25703            provider_session: None,
25704            active_session: None,
25705            environment_profile: None,
25706            bundle: None,
25707            context_id: None,
25708            context: None,
25709            exported_context: None,
25710            task_binding: None,
25711            created_at_unix_ms: 1,
25712            updated_at_unix_ms: 1,
25713            last_error: None,
25714        };
25715        shared.insert_record(original.clone()).unwrap();
25716        let mut collision = original.clone();
25717        collision.display_name = "replacement".to_owned();
25718        let error = shared.insert_record(collision).unwrap_err();
25719        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25720        assert_eq!(shared.record(&record_id).unwrap(), original);
25721    }
25722
25723    #[test]
25724    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25725        let catalog = active_registry().unwrap();
25726        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25727        let workspace = WorkspaceConfig::new(
25728            WorkspaceId::new("primary").unwrap(),
25729            std::env::current_dir().unwrap(),
25730        )
25731        .unwrap();
25732        let shared = NodeShared::new(
25733            handle,
25734            "fixture-token".to_owned(),
25735            NodeId::new("node-1").unwrap(),
25736            vec![workspace.clone()],
25737            vec![agent("claude")],
25738        );
25739        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25740        shared
25741            .insert_record(ManagedSessionRecord {
25742                record_id: record_id.clone(),
25743                display_name: "runtime error".to_owned(),
25744                provider: agent("claude"),
25745                mode: SessionMode::Pty,
25746                state: ManagedSessionState::IdentityPending,
25747                workspace_id: workspace.workspace_id,
25748                canonical_root: opaque_windows_path(workspace.canonical_root),
25749                provider_session: None,
25750                active_session: None,
25751                environment_profile: None,
25752                bundle: None,
25753                context_id: None,
25754                context: None,
25755                exported_context: None,
25756                task_binding: None,
25757                created_at_unix_ms: 1,
25758                updated_at_unix_ms: 1,
25759                last_error: None,
25760            })
25761            .unwrap();
25762
25763        shared
25764            .mark_record_error(
25765                &record_id,
25766                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25767            )
25768            .unwrap();
25769        let record = shared.record(&record_id).unwrap();
25770        assert_eq!(record.state, ManagedSessionState::Unavailable);
25771        assert_eq!(
25772            record.last_error.as_deref(),
25773            Some("provider-runtime-failed"),
25774        );
25775        let snapshot = shared.snapshot();
25776        assert_eq!(snapshot.session_records, vec![record]);
25777    }
25778
25779    /// Regression coverage for the "operator sees `deadline` instead of the
25780    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25781    /// able to learn a command's asynchronous kernel rejection (here,
25782    /// specifically classified as `UnsupportedTransport`) exactly once, and
25783    /// must not still find it after that -- proving the fast path this
25784    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25785    /// loop) drains the reason instead of leaving it to be rediscovered (or
25786    /// missed) on a later poll.
25787    #[test]
25788    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25789        let catalog = active_registry().unwrap();
25790        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25791        let workspace = WorkspaceConfig::new(
25792            WorkspaceId::new("primary").unwrap(),
25793            std::env::current_dir().unwrap(),
25794        )
25795        .unwrap();
25796        let shared = NodeShared::new(
25797            handle,
25798            "fixture-token".to_owned(),
25799            NodeId::new("node-1").unwrap(),
25800            vec![workspace],
25801            vec![agent("claude")],
25802        );
25803
25804        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25805
25806        shared.record_rejected_command(
25807            CommandId(1),
25808            "agent 'claude' does not support transport Acp".to_owned(),
25809            true,
25810        );
25811        // An unrelated command_id must not see another command's reason.
25812        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25813
25814        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25815        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25816        assert!(reason.unsupported_transport);
25817
25818        // Consumed: a second read for the same command_id finds nothing.
25819        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25820
25821        // A rejection unrelated to transport support keeps its own
25822        // classification distinct, so the reader can choose the generic
25823        // `BackendOperationFailed` code instead of misreporting
25824        // `UnsupportedTransport`.
25825        shared.record_rejected_command(
25826            CommandId(3),
25827            "backend busy".to_owned(),
25828            false,
25829        );
25830        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25831        assert!(!unclassified.unsupported_transport);
25832    }
25833
25834    #[test]
25835    fn provider_identity_update_is_ignored_without_exact_session_policy() {
25836        let catalog = active_registry().unwrap();
25837        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25838        let workspace = WorkspaceConfig::new(
25839            WorkspaceId::new("primary").unwrap(),
25840            std::env::current_dir().unwrap(),
25841        )
25842        .unwrap();
25843        let shared = NodeShared::new(
25844            handle,
25845            "fixture-token".to_owned(),
25846            NodeId::new("node-identity-policy").unwrap(),
25847            vec![workspace.clone()],
25848            vec![agent("claude")],
25849        );
25850        let address = SessionAddress {
25851            workspace_id: workspace.workspace_id.clone(),
25852            session: SessionKey {
25853                instance_id: AgentInstanceId(90),
25854                generation: SessionGeneration(1),
25855            },
25856        };
25857        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25858        shared
25859            .insert_record(ManagedSessionRecord {
25860                record_id: record_id.clone(),
25861                display_name: "policy denied".to_owned(),
25862                provider: agent("claude"),
25863                mode: SessionMode::Pty,
25864                state: ManagedSessionState::IdentityPending,
25865                workspace_id: workspace.workspace_id,
25866                canonical_root: opaque_windows_path(workspace.canonical_root),
25867                provider_session: None,
25868                active_session: Some(address.clone()),
25869                environment_profile: None,
25870                bundle: None,
25871                context_id: None,
25872                context: None,
25873                exported_context: None,
25874                task_binding: None,
25875                created_at_unix_ms: 1,
25876                updated_at_unix_ms: 1,
25877                last_error: None,
25878            })
25879            .unwrap();
25880        let event = ControlEvent {
25881            sequence: 1,
25882            command_id: None,
25883            instance_id: address.session.instance_id,
25884            generation: address.session.generation,
25885            event: ControlEventKind::ProviderEvent {
25886                sequence: 1,
25887                source: gate4agent_types::ProviderSource {
25888                    family: AdapterFamily::PtySemantic,
25889                    binding: AdapterBinding::new(
25890                        gate4agent_types::AdapterId::new("claude").unwrap(),
25891                        "fixture/v1",
25892                        gate4agent_types::AdapterVerification::SyntheticFixture,
25893                    )
25894                    .unwrap(),
25895                },
25896                source_sequence: 1,
25897                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25898                    identity: gate4agent_types::ProviderSessionIdentity {
25899                        key: gate4agent_types::ProviderSessionKey::SessionId,
25900                        id: "provider-session".to_owned(),
25901                        transcript_path: None,
25902                    },
25903                },
25904            },
25905        };
25906
25907        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25908
25909        let record = shared.record(&record_id).unwrap();
25910        assert!(record.provider_session.is_none());
25911        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25912        assert_eq!(record.active_session.as_ref(), Some(&address));
25913    }
25914
25915    #[test]
25916    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25917        let secondary_root = temporary_workspace_root("identity-scope");
25918        std::fs::create_dir_all(&secondary_root).unwrap();
25919        let catalog = active_registry().unwrap();
25920        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25921        let primary = WorkspaceConfig::new(
25922            WorkspaceId::new("primary").unwrap(),
25923            std::env::current_dir().unwrap(),
25924        )
25925        .unwrap();
25926        let secondary = WorkspaceConfig::new(
25927            WorkspaceId::new("secondary").unwrap(),
25928            &secondary_root,
25929        )
25930        .unwrap();
25931        let shared = NodeShared::new(
25932            handle,
25933            "fixture-token".to_owned(),
25934            NodeId::new("node-1").unwrap(),
25935            vec![primary.clone(), secondary.clone()],
25936            vec![agent("claude")],
25937        );
25938        let identity = gate4agent_types::ProviderSessionIdentity {
25939            key: gate4agent_types::ProviderSessionKey::SessionId,
25940            id: "provider-session".to_owned(),
25941            transcript_path: None,
25942        };
25943        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25944        shared
25945            .insert_record(ManagedSessionRecord {
25946                record_id: dormant_id.clone(),
25947                display_name: "dormant".to_owned(),
25948                provider: agent("claude"),
25949                mode: SessionMode::Pty,
25950                state: ManagedSessionState::Dormant,
25951                workspace_id: primary.workspace_id,
25952                canonical_root: opaque_windows_path(primary.canonical_root),
25953                provider_session: Some(identity.clone()),
25954                active_session: None,
25955                environment_profile: None,
25956                bundle: None,
25957                context_id: None,
25958                context: None,
25959                exported_context: None,
25960                task_binding: None,
25961                created_at_unix_ms: 1,
25962                updated_at_unix_ms: 1,
25963                last_error: None,
25964            })
25965            .unwrap();
25966        let address = SessionAddress {
25967            workspace_id: secondary.workspace_id.clone(),
25968            session: SessionKey {
25969                instance_id: AgentInstanceId(91),
25970                generation: SessionGeneration(1),
25971            },
25972        };
25973        let current_id = SessionRecordId::new("sr-current").unwrap();
25974        shared
25975            .insert_record(ManagedSessionRecord {
25976                record_id: current_id.clone(),
25977                display_name: "current".to_owned(),
25978                provider: agent("claude"),
25979                mode: SessionMode::Pty,
25980                state: ManagedSessionState::IdentityPending,
25981                workspace_id: secondary.workspace_id,
25982                canonical_root: opaque_windows_path(secondary.canonical_root),
25983                provider_session: None,
25984                active_session: Some(address.clone()),
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: 2,
25992                updated_at_unix_ms: 2,
25993                last_error: None,
25994            })
25995            .unwrap();
25996        shared.bind_managed_session(
25997            &address,
25998            current_id.clone(),
25999            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26000            None,
26001        );
26002        let event = ControlEvent {
26003            sequence: 1,
26004            command_id: None,
26005            instance_id: address.session.instance_id,
26006            generation: address.session.generation,
26007            event: ControlEventKind::ProviderEvent {
26008                sequence: 1,
26009                source: gate4agent_types::ProviderSource {
26010                    family: gate4agent_types::AdapterFamily::PtySemantic,
26011                    binding: gate4agent_types::AdapterBinding::new(
26012                        gate4agent_types::AdapterId::new("claude").unwrap(),
26013                        "fixture/v1",
26014                        gate4agent_types::AdapterVerification::SyntheticFixture,
26015                    )
26016                    .unwrap(),
26017                },
26018                source_sequence: 1,
26019                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26020                    identity,
26021                },
26022            },
26023        };
26024        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26025
26026        let binding = shared
26027            .session_bindings
26028            .lock()
26029            .unwrap_or_else(|poisoned| poisoned.into_inner())
26030            .get(&address.session.instance_id)
26031            .cloned()
26032            .unwrap();
26033        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26034        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26035        let current = shared.record(&current_id).unwrap();
26036        assert_eq!(current.state, ManagedSessionState::Unavailable);
26037        assert_eq!(current.active_session.as_ref(), Some(&address));
26038        assert_eq!(
26039            current.last_error.as_deref(),
26040            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26041        );
26042        std::fs::remove_dir_all(secondary_root).unwrap();
26043    }
26044
26045    #[tokio::test]
26046    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26047        let root = temporary_workspace_root("semantic-provider-session");
26048        std::fs::create_dir_all(&root).unwrap();
26049        let state_path = root.join("state-v1.json");
26050        let catalog = active_registry().unwrap();
26051        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26052        let workspace = WorkspaceConfig::new(
26053            WorkspaceId::new("primary").unwrap(),
26054            &root,
26055        )
26056        .unwrap();
26057        let node_id = NodeId::new("node-semantic-identity").unwrap();
26058        let shared = NodeShared::new_with_incarnation(
26059            handle,
26060            "fixture-token".to_owned(),
26061            node_id.clone(),
26062            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26063            vec![workspace.clone()],
26064            vec![agent("claude")],
26065            ProviderRuntimeStatuses::default(),
26066            None,
26067            Vec::new(),
26068            Vec::new(),
26069            SpawnProfileRegistry::default(),
26070            None,
26071            None,
26072            None,
26073            Some(state_path.clone()),
26074            Vec::new(),
26075            Vec::new(),
26076            Vec::new(),
26077            Vec::new(),
26078            None,
26079            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26080        );
26081        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26082            key: gate4agent_types::ProviderSessionKey::SessionId,
26083            id: "provider-session".to_owned(),
26084            transcript_path: None,
26085        };
26086        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26087        shared
26088            .insert_record(ManagedSessionRecord {
26089                record_id: durable_id.clone(),
26090                display_name: "durable".to_owned(),
26091                provider: agent("claude"),
26092                mode: SessionMode::Pty,
26093                state: ManagedSessionState::Dormant,
26094                workspace_id: workspace.workspace_id.clone(),
26095                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26096                provider_session: Some(persisted_identity.clone()),
26097                active_session: None,
26098                environment_profile: None,
26099                bundle: None,
26100                context_id: None,
26101                context: None,
26102                exported_context: None,
26103                task_binding: None,
26104                created_at_unix_ms: 1,
26105                updated_at_unix_ms: 1,
26106                last_error: None,
26107            })
26108            .unwrap();
26109        let address = SessionAddress {
26110            workspace_id: workspace.workspace_id.clone(),
26111            session: SessionKey {
26112                instance_id: AgentInstanceId(92),
26113                generation: SessionGeneration(2),
26114            },
26115        };
26116        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26117        shared
26118            .insert_record(ManagedSessionRecord {
26119                record_id: pending_id.clone(),
26120                display_name: "pending".to_owned(),
26121                provider: agent("claude"),
26122                mode: SessionMode::Pty,
26123                state: ManagedSessionState::IdentityPending,
26124                workspace_id: workspace.workspace_id.clone(),
26125                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26126                provider_session: None,
26127                active_session: Some(address.clone()),
26128                environment_profile: None,
26129                bundle: None,
26130                context_id: None,
26131                context: None,
26132                exported_context: None,
26133                task_binding: None,
26134                created_at_unix_ms: 2,
26135                updated_at_unix_ms: 2,
26136                last_error: None,
26137            })
26138            .unwrap();
26139        shared.bind_managed_session(
26140            &address,
26141            pending_id.clone(),
26142            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26143            None,
26144        );
26145        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26146            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26147            ..persisted_identity
26148        };
26149        let event = ControlEvent {
26150            sequence: 1,
26151            command_id: None,
26152            instance_id: address.session.instance_id,
26153            generation: address.session.generation,
26154            event: ControlEventKind::ProviderEvent {
26155                sequence: 1,
26156                source: gate4agent_types::ProviderSource {
26157                    family: gate4agent_types::AdapterFamily::PtySemantic,
26158                    binding: gate4agent_types::AdapterBinding::new(
26159                        gate4agent_types::AdapterId::new("claude").unwrap(),
26160                        "fixture/v1",
26161                        gate4agent_types::AdapterVerification::SyntheticFixture,
26162                    )
26163                    .unwrap(),
26164                },
26165                source_sequence: 1,
26166                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26167                    identity: observed_identity.clone(),
26168                },
26169            },
26170        };
26171        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26172
26173        let snapshot = shared.snapshot();
26174        assert_eq!(snapshot.session_records.len(), 1);
26175        let live = &snapshot.session_records[0];
26176        assert_eq!(live.record_id, durable_id);
26177        assert_eq!(live.state, ManagedSessionState::Live);
26178        assert_eq!(live.active_session.as_ref(), Some(&address));
26179        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26180        let binding = shared
26181            .session_bindings
26182            .lock()
26183            .unwrap_or_else(|poisoned| poisoned.into_inner())
26184            .get(&address.session.instance_id)
26185            .cloned()
26186            .unwrap();
26187        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26188
26189        let mut duplicate = live.clone();
26190        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26191        duplicate.display_name = "duplicate".to_owned();
26192        duplicate.state = ManagedSessionState::Dormant;
26193        duplicate.active_session = None;
26194        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26195        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26196        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26197        assert_eq!(shared.snapshot().session_records.len(), 1);
26198
26199        let terminal_size = gate4agent_types::TerminalSize {
26200            rows: 24,
26201            columns: 80,
26202        };
26203        let (first_busy, second_busy) = tokio::join!(
26204            shared.resume_session_record(&durable_id, terminal_size, None),
26205            shared.resume_session_record(&durable_id, terminal_size, None),
26206        );
26207        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26208            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26209            assert_eq!(busy.message, "session-record-busy");
26210        }
26211
26212        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26213        assert_eq!(loaded.records.len(), 1);
26214        assert_eq!(loaded.records[0].record_id, durable_id);
26215        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26216        assert_eq!(
26217            loaded.records[0]
26218                .provider_session
26219                .as_ref()
26220                .unwrap()
26221                .transcript_path,
26222            None,
26223        );
26224        let catalog = active_registry().unwrap();
26225        let (reloaded_handle, _reloaded_runtime) =
26226            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26227        let reloaded = NodeShared::new_with_incarnation(
26228            reloaded_handle,
26229            "fixture-token".to_owned(),
26230            node_id,
26231            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26232            vec![workspace],
26233            vec![agent("claude")],
26234            ProviderRuntimeStatuses::default(),
26235            None,
26236            Vec::new(),
26237            Vec::new(),
26238            SpawnProfileRegistry::default(),
26239            None,
26240            None,
26241            None,
26242            None,
26243            loaded.records,
26244            loaded.managed_worktrees,
26245            loaded.managed_worktree_tombstones,
26246            loaded.materializations,
26247            loaded.warning,
26248            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26249        );
26250        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26251        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26252        std::fs::remove_dir_all(root).unwrap();
26253    }
26254
26255    #[tokio::test]
26256    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26257        let secondary_root = temporary_workspace_root("workspace-restore");
26258        let other_root = temporary_workspace_root("workspace-conflict");
26259        std::fs::create_dir_all(&secondary_root).unwrap();
26260        std::fs::create_dir_all(&other_root).unwrap();
26261        let catalog = active_registry().unwrap();
26262        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26263        let primary = WorkspaceConfig::new(
26264            WorkspaceId::new("primary").unwrap(),
26265            std::env::current_dir().unwrap(),
26266        )
26267        .unwrap();
26268        let secondary = WorkspaceConfig::new(
26269            WorkspaceId::new("secondary").unwrap(),
26270            &secondary_root,
26271        )
26272        .unwrap();
26273        let shared = NodeShared::new(
26274            handle,
26275            "fixture-token".to_owned(),
26276            NodeId::new("node-1").unwrap(),
26277            vec![primary, secondary.clone()],
26278            vec![agent("claude")],
26279        );
26280        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26281        shared
26282            .insert_record(ManagedSessionRecord {
26283                record_id: record_id.clone(),
26284                display_name: "workspace record".to_owned(),
26285                provider: agent("claude"),
26286                mode: SessionMode::Pty,
26287                state: ManagedSessionState::Dormant,
26288                workspace_id: secondary.workspace_id.clone(),
26289                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26290                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26291                    key: gate4agent_types::ProviderSessionKey::SessionId,
26292                    id: "provider-session".to_owned(),
26293                    transcript_path: None,
26294                }),
26295                active_session: None,
26296                environment_profile: None,
26297                bundle: None,
26298                context_id: None,
26299                context: None,
26300                exported_context: None,
26301                task_binding: None,
26302                created_at_unix_ms: 1,
26303                updated_at_unix_ms: 1,
26304                last_error: None,
26305            })
26306            .unwrap();
26307        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26308        let unavailable = shared.record(&record_id).unwrap();
26309        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26310        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26311
26312        let conflict = shared
26313            .register_workspace(
26314                secondary.workspace_id.clone(),
26315                other_root.to_string_lossy().into_owned(),
26316            )
26317            .await
26318            .unwrap_err();
26319        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26320        shared
26321            .register_workspace(
26322                secondary.workspace_id,
26323                secondary_root.to_string_lossy().into_owned(),
26324            )
26325            .await
26326            .unwrap();
26327        let restored = shared.record(&record_id).unwrap();
26328        assert_eq!(restored.state, ManagedSessionState::Dormant);
26329        assert!(restored.last_error.is_none());
26330        std::fs::remove_dir_all(secondary_root).unwrap();
26331        std::fs::remove_dir_all(other_root).unwrap();
26332    }
26333
26334    #[test]
26335    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26336        let error = validate_workspace_request_root(
26337            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26338        )
26339        .unwrap_err();
26340        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26341        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26342        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26343    }
26344
26345    #[test]
26346    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26347        let catalog = active_registry().unwrap();
26348        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26349        let workspace = WorkspaceConfig::new(
26350            WorkspaceId::new("primary").unwrap(),
26351            std::env::current_dir().unwrap(),
26352        )
26353        .unwrap();
26354        let shared = NodeShared::new(
26355            handle,
26356            "fixture-token".to_owned(),
26357            NodeId::new("node-1").unwrap(),
26358            vec![workspace],
26359            vec![agent("claude")],
26360        );
26361        let publishers = 4_u64;
26362        let events_per_publisher = 256_u64;
26363        let start = std::sync::Barrier::new(publishers as usize + 1);
26364        let finished = AtomicU64::new(0);
26365
26366        std::thread::scope(|scope| {
26367            for _ in 0..publishers {
26368                scope.spawn(|| {
26369                    start.wait();
26370                    for _ in 0..events_per_publisher {
26371                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26372                    }
26373                    finished.fetch_add(1, Ordering::Release);
26374                });
26375            }
26376            start.wait();
26377            while finished.load(Ordering::Acquire) != publishers {
26378                assert_resync_history_is_linearized(shared.resync(0));
26379                std::thread::yield_now();
26380            }
26381        });
26382        let response = shared.resync(0);
26383        assert_resync_history_is_linearized(response.clone());
26384        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26385            unreachable!("resync helper returned another response");
26386        };
26387        assert_eq!(event_sequence, publishers * events_per_publisher);
26388        assert_eq!(events.len() as u64, event_sequence);
26389    }
26390
26391    fn assert_resync_history_is_linearized(response: NodeResponse) {
26392        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26393            unreachable!("resync helper returned another response");
26394        };
26395        assert!(events.windows(2).all(|pair| {
26396            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26397        }));
26398        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26399    }
26400
26401    #[test]
26402    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26403        assert_eq!(
26404            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26405            r"C:\repo\worktree",
26406        );
26407        assert_eq!(
26408            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26409            r"\\server\share\repo",
26410        );
26411        assert!(validate_workspace_root(
26412            &WorkspaceId::new("network").unwrap(),
26413            r"\\server\share\repo",
26414        )
26415        .is_err());
26416        let workspace = WorkspaceConfig::new(
26417            WorkspaceId::new("primary").unwrap(),
26418            std::env::current_dir().unwrap(),
26419        )
26420        .unwrap();
26421        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26422    }
26423
26424    #[test]
26425    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26426        let catalog = active_registry().unwrap();
26427        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26428        let workspace_id = WorkspaceId::new("primary").unwrap();
26429        let workspace = WorkspaceConfig::new(
26430            workspace_id.clone(),
26431            std::env::current_dir().unwrap(),
26432        )
26433        .unwrap();
26434        let shared = NodeShared::new(
26435            handle,
26436            "fixture-token".to_owned(),
26437            NodeId::new("node-1").unwrap(),
26438            vec![workspace],
26439            vec![agent("claude")],
26440        );
26441        let instance_id = AgentInstanceId(17);
26442        let original = SessionAddress {
26443            workspace_id,
26444            session: SessionKey {
26445                instance_id,
26446                generation: SessionGeneration(3),
26447            },
26448        };
26449        shared.bind_session(
26450            instance_id,
26451            original.workspace_id.clone(),
26452            original.session.generation,
26453        );
26454        shared
26455            .arm_resume(
26456                &original,
26457                CommandId(41),
26458                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26459            )
26460            .unwrap();
26461
26462        shared.publish_control(ControlEvent {
26463            sequence: 1,
26464            command_id: None,
26465            instance_id,
26466            generation: SessionGeneration(4),
26467            event: ControlEventKind::Running { process_id: None },
26468        });
26469        assert_eq!(
26470            shared.address_for(instance_id, SessionGeneration(3)),
26471            Some(original.clone()),
26472        );
26473        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26474
26475        shared.publish_control(ControlEvent {
26476            sequence: 2,
26477            command_id: Some(CommandId(42)),
26478            instance_id,
26479            generation: SessionGeneration(3),
26480            event: ControlEventKind::CommandRejected {
26481                message: "unrelated command".to_owned(),
26482            },
26483        });
26484
26485        shared.publish_control(ControlEvent {
26486            sequence: 3,
26487            command_id: None,
26488            instance_id,
26489            generation: SessionGeneration(4),
26490            event: ControlEventKind::ResumeAuthorized {
26491                session: gate4agent_types::ResumeSessionSummary {
26492                    key: gate4agent_types::ProviderSessionKey::SessionId,
26493                    id: "fixture-session".to_owned(),
26494                },
26495            },
26496        });
26497        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26498        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26499        assert_eq!(rebound.workspace_id, original.workspace_id);
26500        let history = shared
26501            .history
26502            .lock()
26503            .unwrap_or_else(|poisoned| poisoned.into_inner());
26504        assert!(history.events.iter().any(|envelope| matches!(
26505            &envelope.event,
26506            NodeEvent::Control { address, event }
26507                if address == &rebound
26508                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26509        )));
26510    }
26511
26512    #[test]
26513    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26514        for agent in ["claude", "codex", "kimi"] {
26515            assert_eq!(
26516                prompt_framing(&AgentId::new(agent).unwrap()),
26517                PromptFraming::BracketedPaste
26518            );
26519        }
26520    }
26521
26522    fn generous_walk_deadline() -> Instant {
26523        Instant::now() + Duration::from_secs(30)
26524    }
26525
26526    #[test]
26527    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26528        let root = temporary_workspace_root("tree");
26529        std::fs::create_dir_all(root.join("src")).unwrap();
26530        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26531        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26532        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26533        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26534        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26535        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26536        // real repository's own build layout) that carries the Cache
26537        // Directory Tagging Specification marker cargo writes into every
26538        // `--target-dir` it creates: must be skipped even though its name
26539        // does not exactly match `target`.
26540        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26541        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26542        // A directory that merely *starts with* `target` but carries no
26543        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26544        // on the name alone, so this stays in the response.
26545        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26546        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26547
26548        let (entries, truncated, _budget) =
26549            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26550        assert!(!truncated);
26551        assert!(entries.iter().any(|entry| {
26552            entry.relative_path.as_utf8() == Some("src")
26553                && entry.kind == WorkspaceEntryKind::Directory
26554        }));
26555        assert!(entries.iter().any(|entry| {
26556            entry.relative_path.as_utf8() == Some("src/lib.rs")
26557                && entry.kind == WorkspaceEntryKind::File
26558        }));
26559        assert!(entries.iter().any(|entry| {
26560            entry.relative_path.as_utf8() == Some("target-notes")
26561                && entry.kind == WorkspaceEntryKind::Directory
26562        }));
26563        assert!(entries.iter().any(|entry| {
26564            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26565                && entry.kind == WorkspaceEntryKind::File
26566        }));
26567        assert!(entries.iter().all(|entry| {
26568            let path = entry.relative_path.as_utf8().unwrap();
26569            !path.starts_with(".git")
26570                && !(path == "target" || path.starts_with("target/"))
26571                && !(path == "target-a2" || path.starts_with("target-a2/"))
26572                && !path.starts_with("node_modules")
26573        }));
26574
26575        std::fs::remove_dir_all(&root).unwrap();
26576    }
26577
26578    #[test]
26579    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26580        let root = temporary_workspace_root("tree-entry-cap");
26581        std::fs::create_dir_all(&root).unwrap();
26582        for index in 0..20 {
26583            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26584        }
26585
26586        let (entries, truncated, budget) =
26587            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26588        assert!(truncated);
26589        assert!(budget.entry_cap_exceeded);
26590        assert!(!budget.time_budget_exceeded);
26591        assert!(entries.len() <= 5);
26592
26593        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26594            .expect("an entry-cap-truncated walk must report a truncation marker");
26595        assert!(inspection_truncation.walk_entry_cap_exceeded);
26596        assert!(!inspection_truncation.walk_time_budget_exceeded);
26597        assert!(!inspection_truncation.git_time_budget_exceeded);
26598        assert!(inspection_truncation.entries_visited >= 5);
26599
26600        std::fs::remove_dir_all(&root).unwrap();
26601    }
26602
26603    #[test]
26604    fn workspace_walk_within_budget_reports_no_truncation() {
26605        let root = temporary_workspace_root("tree-no-truncation");
26606        std::fs::create_dir_all(&root).unwrap();
26607        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26608
26609        let (_entries, truncated, budget) =
26610            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26611        assert!(!truncated);
26612        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26613
26614        std::fs::remove_dir_all(&root).unwrap();
26615    }
26616
26617    #[test]
26618    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26619        let root = temporary_workspace_root("tree-prefix-siblings");
26620        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26621        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26622        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26623        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26624
26625        let (entries, truncated, _budget) =
26626            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26627        assert!(!truncated);
26628        let paths = entries
26629            .iter()
26630            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26631            .collect::<Vec<_>>();
26632        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26633        assert_eq!(
26634            paths,
26635            vec![
26636                "gate4agent-c2".to_owned(),
26637                "gate4agent-c2-client".to_owned(),
26638                "gate4agent-c2-client/lib.rs".to_owned(),
26639                "gate4agent-c2/src".to_owned(),
26640                "gate4agent-c2/src/lib.rs".to_owned(),
26641            ],
26642        );
26643
26644        std::fs::remove_dir_all(&root).unwrap();
26645    }
26646
26647    #[test]
26648    fn git_parsers_cap_status_and_commit_results() {
26649        let mut status_output = Vec::new();
26650        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26651            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26652        }
26653        let mut snapshot = git_snapshot_for_parser();
26654        parse_git_status(&status_output, &mut snapshot);
26655        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26656        assert!(snapshot.truncated);
26657        assert_eq!(snapshot.status[0].index_status, " ");
26658        assert_eq!(snapshot.status[0].worktree_status, "M");
26659
26660        snapshot.truncated = false;
26661        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26662            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26663            .collect::<String>();
26664        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26665        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26666        assert!(snapshot.truncated);
26667    }
26668
26669    #[test]
26670    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26671        let mut snapshot = git_snapshot_for_parser();
26672        parse_git_status(
26673            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26674            &mut snapshot,
26675        );
26676
26677        assert!(!snapshot.truncated);
26678        assert_eq!(snapshot.status.len(), 3);
26679        assert_eq!(snapshot.status[0].index_status, "R");
26680        assert_eq!(snapshot.status[0].worktree_status, " ");
26681        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26682        assert_eq!(
26683            snapshot.status[0]
26684                .previous_path
26685                .as_ref()
26686                .and_then(RepositoryPath::as_utf8),
26687            Some("src/previous name.rs"),
26688        );
26689        assert_eq!(snapshot.status[1].index_status, " ");
26690        assert_eq!(snapshot.status[1].worktree_status, "C");
26691        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26692        assert_eq!(
26693            snapshot.status[1]
26694                .previous_path
26695                .as_ref()
26696                .and_then(RepositoryPath::as_utf8),
26697            Some("src/original.rs"),
26698        );
26699        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26700        assert!(snapshot.status[2].previous_path.is_none());
26701    }
26702
26703    #[test]
26704    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26705        let mut snapshot = git_snapshot_for_parser();
26706        parse_git_status(
26707            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26708            &mut snapshot,
26709        );
26710
26711        assert!(snapshot.truncated);
26712        assert_eq!(snapshot.status.len(), 1);
26713        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26714        assert!(snapshot.status[0].previous_path.is_none());
26715    }
26716
26717    #[tokio::test]
26718    async fn observer_can_browse_host_directories_without_controller() {
26719        let root = temporary_workspace_root("host-directory-observer");
26720        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26721        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26722        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26723        let workspace_id = WorkspaceId::new("primary").unwrap();
26724        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26725        let catalog = active_registry().unwrap();
26726        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26727        let shared = NodeShared::new(
26728            handle,
26729            "fixture-token".to_owned(),
26730            NodeId::new("node-1").unwrap(),
26731            vec![workspace],
26732            vec![agent("claude")],
26733        );
26734        let request = NodeRequest::BrowseHostDirectories {
26735            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26736            after: None,
26737        };
26738        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26739            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26740        }));
26741        assert!(!request_uses_unnegotiated_capability(
26742            &request,
26743            &baseline_capabilities().unwrap(),
26744        ));
26745        assert!(request_uses_unnegotiated_capability(&request, &[]));
26746
26747        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26748            .await
26749            .unwrap();
26750        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26751            panic!("host directory browse returned another response");
26752        };
26753        assert_eq!(
26754            listing
26755                .entries
26756                .iter()
26757                .map(|entry| entry.display_name.as_str())
26758                .collect::<Vec<_>>(),
26759            vec!["directory-a", "directory-b"],
26760        );
26761        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26762        assert!(!listing.incomplete);
26763        std::fs::remove_dir_all(root).unwrap();
26764    }
26765
26766    #[tokio::test]
26767    async fn observer_can_inspect_only_a_registered_workspace() {
26768        let root = temporary_workspace_root("observer");
26769        std::fs::create_dir_all(root.join("src")).unwrap();
26770        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26771        let workspace_id = WorkspaceId::new("primary").unwrap();
26772        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26773        let catalog = active_registry().unwrap();
26774        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26775        let shared = NodeShared::new(
26776            handle,
26777            "fixture-token".to_owned(),
26778            NodeId::new("node-1").unwrap(),
26779            vec![workspace],
26780            vec![agent("claude")],
26781        );
26782
26783        let response = process_request_inner(
26784            &shared,
26785            77,
26786            ClientRole::Observer,
26787            NodeRequest::InspectWorkspace {
26788                workspace_id: workspace_id.clone(),
26789            },
26790        )
26791        .await
26792        .unwrap();
26793        let NodeResponse::WorkspaceInspected { inspection } = response else {
26794            panic!("workspace inspection returned another response");
26795        };
26796        assert_eq!(inspection.workspace_id, workspace_id);
26797        assert!(inspection.entries.iter().any(|entry| {
26798            entry.relative_path.as_utf8() == Some("src/main.rs")
26799        }));
26800
26801        let unknown = process_request_inner(
26802            &shared,
26803            77,
26804            ClientRole::Observer,
26805            NodeRequest::InspectWorkspace {
26806                workspace_id: WorkspaceId::new("unknown").unwrap(),
26807            },
26808        )
26809        .await
26810        .unwrap_err();
26811        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26812        std::fs::remove_dir_all(&root).unwrap();
26813    }
26814
26815    #[test]
26816    fn context_pack_source_accepts_only_running_or_successful_exit() {
26817        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26818        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26819            exit_code: Some(0),
26820        }));
26821        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26822            exit_code: Some(1),
26823        }));
26824        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26825            exit_code: None,
26826        }));
26827        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26828            message: "provider failed".to_owned(),
26829        }));
26830        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26831        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26832        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26833    }
26834
26835    #[test]
26836    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26837        let git = git_snapshot_for_parser();
26838        let first = ContextPackRepositoryObservation {
26839            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26840            git: git.clone(),
26841            files: Vec::new(),
26842        };
26843        let second = ContextPackRepositoryObservation {
26844            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26845            git,
26846            files: Vec::new(),
26847        };
26848
26849        let error = stable_context_pack_repository(first, second).unwrap_err();
26850
26851        assert_eq!(
26852            error.code,
26853            NodeFailureCode::ContextPackMaterializationFailed,
26854        );
26855    }
26856
26857    #[test]
26858    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26859        let git = git_snapshot_for_parser();
26860        let first = ContextPackRepositoryObservation {
26861            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26862            git: git.clone(),
26863            files: vec![ContextPackRepositoryFileSource::utf8(
26864                "README.md",
26865                "first\n".to_owned(),
26866                6,
26867            )],
26868        };
26869        let second = ContextPackRepositoryObservation {
26870            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26871            git,
26872            files: vec![ContextPackRepositoryFileSource::utf8(
26873                "README.md",
26874                "other\n".to_owned(),
26875                6,
26876            )],
26877        };
26878
26879        let error = stable_context_pack_repository(first, second).unwrap_err();
26880
26881        assert_eq!(
26882            error.code,
26883            NodeFailureCode::ContextPackMaterializationFailed,
26884        );
26885    }
26886
26887    #[tokio::test]
26888    async fn native_session_catalog_zero_active_session_fixture() {
26889        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26890        let history_root = temporary_workspace_root("native-catalog-history");
26891        let outside_root = temporary_workspace_root("native-catalog-outside");
26892        std::fs::create_dir_all(&workspace_root).unwrap();
26893        std::fs::create_dir_all(&history_root).unwrap();
26894        std::fs::create_dir_all(&outside_root).unwrap();
26895        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26896            [
26897                serde_json::json!({
26898                    "type": "user",
26899                    "sessionId": session_id,
26900                    "cwd": cwd.to_str().unwrap(),
26901                    "message": { "content": "raw user text must not cross the catalog wire" }
26902                }),
26903                serde_json::json!({
26904                    "type": "assistant",
26905                    "sessionId": session_id,
26906                    "cwd": cwd.to_str().unwrap(),
26907                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26908                }),
26909                serde_json::json!({
26910                    "type": "ai-title",
26911                    "sessionId": session_id,
26912                    "aiTitle": title
26913                }),
26914            ]
26915            .into_iter()
26916            .map(|record| record.to_string())
26917            .collect::<Vec<_>>()
26918            .join("\n")
26919        };
26920        std::fs::write(
26921            history_root.join("stable-session.jsonl"),
26922            transcript("stable-session", &workspace_root, "Workspace review"),
26923        )
26924        .unwrap();
26925        std::fs::write(
26926            history_root.join("outside-session.jsonl"),
26927            transcript("outside-session", &outside_root, "Outside review"),
26928        )
26929        .unwrap();
26930        let history = NativeHistoryConfig::new(vec![
26931            NativeHistoryRoot::new(
26932                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26933                HistorySourceLayout::SingleNdjson,
26934                history_root.clone(),
26935            )
26936            .unwrap(),
26937        ])
26938        .unwrap();
26939        let catalog = active_registry().unwrap();
26940        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26941        assert!(handle.snapshot().sessions.is_empty());
26942        let workspace_id = WorkspaceId::new("primary").unwrap();
26943        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26944        let mut shared = NodeShared::new(
26945            handle,
26946            "fixture-token".to_owned(),
26947            NodeId::new("node-native-catalog").unwrap(),
26948            vec![workspace],
26949            vec![agent("claude")],
26950        );
26951        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26952            NativeSessionCatalogAuthority::new(history),
26953        )));
26954        let route = NativeSessionCatalogRoute::workspace(
26955            workspace_id.clone(),
26956            agent("claude"),
26957        );
26958        let request = NodeRequest::CatalogNativeSessions {
26959            route: route.clone(),
26960            limit: 2,
26961        };
26962        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26963            .await
26964            .unwrap_err();
26965        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26966        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26967            .await
26968            .unwrap();
26969        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26970            panic!("native catalog returned a different response");
26971        };
26972        let summary = summary.expect("node must emit native session catalog summary metadata");
26973        assert_eq!(entries.len(), 1);
26974        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26975        assert_eq!(entries[0].external_group, None);
26976        assert_eq!(entries[0].record_id, None);
26977        assert_eq!(entries[0].title, None);
26978        assert_eq!(entries[0].model, None);
26979        assert_eq!(entries[0].message_count, 0);
26980        let encoded_entries = serde_json::to_string(&entries).unwrap();
26981        assert!(!encoded_entries.contains("stable-session"));
26982        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26983        let page_request = NodeRequest::PageNativeSessions {
26984            route: route.clone(),
26985            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26986            catalog_revision: summary.catalog_revision,
26987            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26988            after_selection_id: None,
26989            limit: 2,
26990        };
26991        let observer = process_request_inner(
26992            &shared,
26993            7,
26994            ClientRole::Observer,
26995            page_request.clone(),
26996        )
26997        .await
26998        .unwrap_err();
26999        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27000        let page_response = process_request_inner(
27001            &shared,
27002            7,
27003            ClientRole::Operator,
27004            page_request.clone(),
27005        )
27006        .await
27007        .unwrap();
27008        assert!(matches!(
27009            page_response,
27010            NodeResponse::NativeSessionsPaged { page, .. }
27011                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27012        ));
27013        let stale = process_request_inner(
27014            &shared,
27015            7,
27016            ClientRole::Operator,
27017            NodeRequest::PageNativeSessions {
27018                route: route.clone(),
27019                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27020                catalog_revision: summary.catalog_revision.wrapping_add(1),
27021                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27022                after_selection_id: None,
27023                limit: 2,
27024            },
27025        )
27026        .await
27027        .unwrap_err();
27028        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27029        let selection = NativeSessionSelection {
27030            route: route.clone(),
27031            catalog_revision: summary.catalog_revision,
27032            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27033            selection_id: entries[0].selection_id.clone(),
27034        };
27035        let preview_response = process_request_inner(
27036            &shared,
27037            7,
27038            ClientRole::Operator,
27039            NodeRequest::PreviewNativeSession {
27040                selection: selection.clone(),
27041                message_limit: 1,
27042            },
27043        )
27044        .await
27045        .unwrap();
27046        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27047            panic!("native preview returned a different response");
27048        };
27049        assert_eq!(preview.messages.len(), 1);
27050        assert!(preview.message_count_exact);
27051        assert_eq!(preview.messages[0].text, "raw answer");
27052        assert!(preview.truncated);
27053
27054        let index_request = NodeRequest::IndexNativeSession {
27055            selection: selection.clone(),
27056            display_name: "Saved Claude".to_owned(),
27057        };
27058        let observer = process_request_inner(
27059            &shared,
27060            7,
27061            ClientRole::Observer,
27062            index_request.clone(),
27063        )
27064        .await
27065        .unwrap_err();
27066        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27067        let without_controller = process_request_inner(
27068            &shared,
27069            7,
27070            ClientRole::Operator,
27071            index_request.clone(),
27072        )
27073        .await
27074        .unwrap_err();
27075        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27076        let acquired = process_request_inner(
27077            &shared,
27078            7,
27079            ClientRole::Operator,
27080            NodeRequest::AcquireController { lease_ms: 5_000 },
27081        )
27082        .await
27083        .unwrap();
27084        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27085        let indexed = process_request_inner(
27086            &shared,
27087            7,
27088            ClientRole::Operator,
27089            index_request,
27090        )
27091        .await
27092        .unwrap();
27093        assert!(indexed.native_session_index_contract_is_valid());
27094        let NodeResponse::NativeSessionIndexed {
27095            selection: echoed_selection,
27096            record,
27097        } = indexed else {
27098            panic!("native session index returned a different response");
27099        };
27100        assert_eq!(echoed_selection, selection);
27101        let recataloged = process_request_inner(
27102            &shared,
27103            7,
27104            ClientRole::Operator,
27105            NodeRequest::CatalogNativeSessions {
27106                route,
27107                limit: 2,
27108            },
27109        )
27110        .await
27111        .unwrap();
27112        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27113            panic!("native catalog returned a different response after indexing");
27114        };
27115        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27116
27117        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27118        let external_catalog = process_request_inner(
27119            &shared,
27120            7,
27121            ClientRole::Operator,
27122            NodeRequest::CatalogNativeSessions {
27123                route: unregistered_route.clone(),
27124                limit: 2,
27125            },
27126        )
27127        .await
27128        .unwrap();
27129        let external_json = serde_json::to_string(&external_catalog).unwrap();
27130        assert!(!external_json.contains("outside-session"));
27131        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27132        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27133        assert!(!external_json.contains("\"session_id\""));
27134        assert!(!external_json.contains("\"cwd\""));
27135        assert!(!external_json.contains("\"path\""));
27136        let NodeResponse::NativeSessionsCataloged {
27137            route: echoed_route,
27138            entries: external_entries,
27139            summary: Some(external_summary),
27140        } = external_catalog
27141        else {
27142            panic!("unregistered native catalog returned a different response");
27143        };
27144        assert_eq!(echoed_route, unregistered_route);
27145        assert_eq!(external_entries.len(), 1);
27146        assert_eq!(external_entries[0].record_id, None);
27147        let external_group = external_entries[0]
27148            .external_group
27149            .as_ref()
27150            .expect("unregistered session must have an external group");
27151        assert_eq!(
27152            external_group.kind,
27153            gate4agent_types::NativeSessionExternalGroupKind::Project,
27154        );
27155        assert!(external_group.group_id.starts_with("external-"));
27156        assert_eq!(
27157            external_group.display_name,
27158            outside_root.file_name().unwrap().to_string_lossy(),
27159        );
27160        let external_selection = NativeSessionSelection {
27161            route: unregistered_route,
27162            catalog_revision: external_summary.catalog_revision,
27163            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27164            selection_id: external_entries[0].selection_id.clone(),
27165        };
27166        let external_preview = process_request_inner(
27167            &shared,
27168            7,
27169            ClientRole::Operator,
27170            NodeRequest::PreviewNativeSession {
27171                selection: external_selection.clone(),
27172                message_limit: 1,
27173            },
27174        )
27175        .await
27176        .unwrap();
27177        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27178        assert!(!external_preview_json.contains("outside-session"));
27179        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27180        assert!(!external_preview_json.contains("\"session_id\""));
27181        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27182            panic!("unregistered native preview returned a different response");
27183        };
27184        assert_eq!(preview.messages.len(), 1);
27185        let external_index = process_request_inner(
27186            &shared,
27187            7,
27188            ClientRole::Operator,
27189            NodeRequest::IndexNativeSession {
27190                selection: external_selection,
27191                display_name: "External Claude".to_owned(),
27192            },
27193        )
27194        .await
27195        .unwrap_err();
27196        assert_eq!(
27197            external_index.code,
27198            NodeFailureCode::WorkspaceRegistrationRequired,
27199        );
27200        let record_response = process_request_inner(
27201            &shared,
27202            7,
27203            ClientRole::Operator,
27204            NodeRequest::PreviewSessionRecord {
27205                record_id: record.record_id.clone(),
27206                message_limit: 2,
27207            },
27208        )
27209        .await
27210        .unwrap();
27211        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27212            panic!("record preview returned a different response");
27213        };
27214        assert_eq!(record_id, record.record_id);
27215        assert_eq!(preview.messages.len(), 2);
27216        let encoded = serde_json::to_string(&preview).unwrap();
27217        assert!(!encoded.contains("stable-session"));
27218        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27219        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27220            panic!("record preview history summary returned a different response");
27221        };
27222        let summaries = events
27223            .iter()
27224            .filter_map(|envelope| match &envelope.event {
27225                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27226                    if record_id == &record.record_id =>
27227                {
27228                    Some(*summary)
27229                }
27230                _ => None,
27231            })
27232            .collect::<Vec<_>>();
27233        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27234        let summary_json = serde_json::to_string(&summaries).unwrap();
27235        for private in [
27236            "stable-session",
27237            "raw user text must not cross the catalog wire",
27238            "raw answer",
27239            "Workspace review",
27240            "claude-sonnet",
27241        ] {
27242            assert!(!summary_json.contains(private), "{private}");
27243        }
27244        let identity = record.provider_session.clone().unwrap();
27245        assert_eq!(
27246            shared.revalidate_session_record_preview(&record, &identity),
27247            Ok(())
27248        );
27249        shared.remove_record_memory(&record.record_id);
27250        assert_eq!(
27251            shared
27252                .revalidate_session_record_preview(&record, &identity)
27253                .unwrap_err()
27254                .code,
27255            NodeFailureCode::UnknownSessionRecord
27256        );
27257        let released = process_request_inner(
27258            &shared,
27259            7,
27260            ClientRole::Operator,
27261            NodeRequest::ReleaseController,
27262        )
27263        .await
27264        .unwrap();
27265        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27266        assert!(shared.controller_state().is_none());
27267        std::fs::remove_dir_all(workspace_root).unwrap();
27268        std::fs::remove_dir_all(history_root).unwrap();
27269        std::fs::remove_dir_all(outside_root).unwrap();
27270    }
27271
27272    #[tokio::test]
27273    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27274        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27275        std::fs::create_dir_all(&history_root).unwrap();
27276        let history = NativeHistoryConfig::new(vec![
27277            NativeHistoryRoot::new(
27278                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27279                HistorySourceLayout::SingleNdjson,
27280                history_root.clone(),
27281            )
27282            .unwrap(),
27283        ])
27284        .unwrap();
27285        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27286        let held = catalog.lock().unwrap();
27287        let result = timeout(
27288            Duration::from_secs(1),
27289            run_native_session_catalog_operation(
27290                Arc::clone(&catalog),
27291                "native session catalog",
27292                |_| Ok::<(), std::convert::Infallible>(()),
27293            ),
27294        )
27295        .await
27296        .expect("concurrent native catalog request must not wait for the operation deadline")
27297        .unwrap_err();
27298        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27299        drop(held);
27300        std::fs::remove_dir_all(history_root).unwrap();
27301    }
27302
27303    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27304        let unique = std::time::SystemTime::now()
27305            .duration_since(std::time::UNIX_EPOCH)
27306            .unwrap()
27307            .as_nanos();
27308        std::env::temp_dir().join(format!(
27309            "gate4agent-node-{label}-{}-{unique}",
27310            std::process::id(),
27311        ))
27312    }
27313
27314    fn git_snapshot_for_parser() -> GitSnapshot {
27315        GitSnapshot {
27316            is_repository: true,
27317            branch: Some("main".to_owned()),
27318            status: Vec::new(),
27319            recent_commits: Vec::new(),
27320            worktrees: Vec::new(),
27321            managed_worktree: None,
27322            truncated: false,
27323            diagnostic: None,
27324        }
27325    }
27326
27327    #[tokio::test]
27328    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27329        let root = temporary_workspace_root("managed-git-scope");
27330        std::fs::create_dir_all(&root).unwrap();
27331        run_test_git(&root, &["init"]);
27332        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27333        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27334        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27335        run_test_git(&root, &["add", "seed.txt"]);
27336        run_test_git(&root, &["commit", "-m", "seed"]);
27337        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27338
27339        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27340        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27341        let catalog = active_registry().unwrap();
27342        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27343        let shared = NodeShared::new(
27344            handle,
27345            "fixture-token".to_owned(),
27346            NodeId::new("node-1").unwrap(),
27347            vec![workspace],
27348            vec![agent("claude")],
27349        );
27350        let mut lease = ManagedWorktreeLeaseRecord {
27351            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27352            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27353            workspace_id: workspace_id.clone(),
27354            profile_id: WorktreeProfileId::new("default").unwrap(),
27355            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27356            target_root: root.to_string_lossy().into_owned(),
27357            branch: "gate4agent/a".to_owned(),
27358            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27359            expected_head: None,
27360            retention: ManagedWorktreeRetention::Retain,
27361            state: ManagedWorktreeLeaseState::InUse,
27362            session_holders: vec![ManagedWorktreeSessionHolder {
27363                incarnation_id: shared.incarnation_id,
27364                instance_id: AgentInstanceId(7),
27365                generation: SessionGeneration(1),
27366            }],
27367            record_holders: Vec::new(),
27368            cleanup_failure: None,
27369            created_at_unix_ms: 1,
27370            updated_at_unix_ms: 1,
27371        };
27372        *shared
27373            .managed_worktrees
27374            .lock()
27375            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27376            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27377
27378        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27379        let scope = inspection.git.managed_worktree.unwrap();
27380        assert_eq!(scope.lease_id, lease.lease_id);
27381        assert_eq!(scope.branch, "gate4agent/a");
27382        assert_eq!(scope.active_session_count, 1);
27383
27384        lease.branch = "gate4agent/b".to_owned();
27385        *shared
27386            .managed_worktrees
27387            .lock()
27388            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27389            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27390        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27391        assert!(mismatch.git.managed_worktree.is_none());
27392        std::fs::remove_dir_all(root).unwrap();
27393    }
27394
27395    #[tokio::test]
27396    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27397        let root = temporary_workspace_root("workspace-file-write-controller");
27398        std::fs::create_dir_all(root.join("src")).unwrap();
27399        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27400        let workspace_id = WorkspaceId::new("primary").unwrap();
27401        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27402        let catalog = active_registry().unwrap();
27403        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27404        let shared = NodeShared::new(
27405            handle,
27406            "fixture-token".to_owned(),
27407            NodeId::new("node-1").unwrap(),
27408            vec![workspace],
27409            vec![agent("claude")],
27410        );
27411        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27412        let expected_revision = workspace_file_revision(b"old\n");
27413        let request = NodeRequest::WriteWorkspaceFile {
27414            workspace_id: workspace_id.clone(),
27415            path: path.clone(),
27416            expected_revision: expected_revision.clone(),
27417            text: "new\n".to_owned(),
27418        };
27419        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27420            .await
27421            .unwrap_err();
27422        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27423        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27424            .await
27425            .unwrap_err();
27426        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27427        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27428        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27429        let NodeResponse::WorkspaceFileWritten { file } = response else {
27430            panic!("workspace file write returned another response");
27431        };
27432        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27433        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27434        let stale = process_request_inner(
27435            &shared,
27436            77,
27437            ClientRole::Operator,
27438            NodeRequest::WriteWorkspaceFile {
27439                workspace_id,
27440                path,
27441                expected_revision,
27442                text: "lost update\n".to_owned(),
27443            },
27444        )
27445        .await
27446        .unwrap_err();
27447        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27448        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27449        std::fs::remove_dir_all(root).unwrap();
27450    }
27451
27452    #[tokio::test]
27453    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27454        let root = temporary_workspace_root("workspace-entry-create-controller");
27455        std::fs::create_dir_all(root.join("src")).unwrap();
27456        let workspace_id = WorkspaceId::new("primary").unwrap();
27457        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27458        let catalog = active_registry().unwrap();
27459        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27460        let shared = NodeShared::new(
27461            handle,
27462            "fixture-token".to_owned(),
27463            NodeId::new("node-1").unwrap(),
27464            vec![workspace],
27465            vec![agent("claude")],
27466        );
27467        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27468        let request = NodeRequest::CreateWorkspaceFile {
27469            workspace_id: workspace_id.clone(),
27470            path: path.clone(),
27471        };
27472        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27473            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27474        }));
27475        assert!(!request_uses_unnegotiated_capability(
27476            &request,
27477            &baseline_capabilities().unwrap(),
27478        ));
27479        assert!(request_uses_unnegotiated_capability(&request, &[]));
27480        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27481            .await
27482            .unwrap_err();
27483        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27484        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27485            .await
27486            .unwrap_err();
27487        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27488        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27489
27490        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27491            .await
27492            .unwrap();
27493        let NodeResponse::WorkspaceFileCreated { file } = response else {
27494            panic!("workspace file create returned another response");
27495        };
27496        assert_eq!(file.workspace_id, workspace_id);
27497        assert_eq!(file.path, path);
27498        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27499        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27500
27501        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27502            .await
27503            .unwrap_err();
27504        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27505
27506        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27507        let response = process_request_inner(
27508            &shared,
27509            77,
27510            ClientRole::Operator,
27511            NodeRequest::CreateWorkspaceDirectory {
27512                workspace_id: workspace_id.clone(),
27513                path: directory_path.clone(),
27514            },
27515        )
27516        .await
27517        .unwrap();
27518        assert_eq!(
27519            response,
27520            NodeResponse::WorkspaceDirectoryCreated {
27521                workspace_id: workspace_id.clone(),
27522                entry: WorkspaceEntry {
27523                    relative_path: directory_path,
27524                    kind: WorkspaceEntryKind::Directory,
27525                },
27526            },
27527        );
27528        assert!(root.join("src/nested").is_dir());
27529
27530        let missing_parent = process_request_inner(
27531            &shared,
27532            77,
27533            ClientRole::Operator,
27534            NodeRequest::CreateWorkspaceFile {
27535                workspace_id: workspace_id.clone(),
27536                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27537            },
27538        )
27539        .await
27540        .unwrap_err();
27541        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27542        let non_directory_parent = process_request_inner(
27543            &shared,
27544            77,
27545            ClientRole::Operator,
27546            NodeRequest::CreateWorkspaceFile {
27547                workspace_id,
27548                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27549            },
27550        )
27551        .await
27552        .unwrap_err();
27553        assert_eq!(
27554            non_directory_parent.code,
27555            NodeFailureCode::RepositoryParentNotDirectory,
27556        );
27557        std::fs::remove_dir_all(root).unwrap();
27558    }
27559
27560    #[tokio::test]
27561    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27562        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27563        std::fs::create_dir_all(root.join("src")).unwrap();
27564        #[cfg(windows)]
27565        let root = std::fs::canonicalize(root).unwrap();
27566        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27567        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27568        let barrier = Arc::new(std::sync::Barrier::new(2));
27569        let done = Arc::new(AtomicBool::new(false));
27570        let task_commit_state = Arc::clone(&commit_state);
27571        let task_barrier = Arc::clone(&barrier);
27572        let task_done = Arc::clone(&done);
27573        let task_root = root.clone();
27574        let task = tokio::task::spawn_blocking(move || {
27575            task_barrier.wait();
27576            let result = create_workspace_file_on_disk(
27577                &task_root,
27578                &path,
27579                &task_commit_state,
27580            );
27581            task_done.store(true, Ordering::Release);
27582            result
27583        });
27584
27585        let error = settle_workspace_entry_create(
27586            task,
27587            Arc::clone(&commit_state),
27588            Duration::from_millis(10),
27589            "fixture deadline",
27590            "fixture task failed",
27591        )
27592        .await
27593        .unwrap_err();
27594        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27595        assert_eq!(
27596            commit_state.load(Ordering::Acquire),
27597            WORKSPACE_ENTRY_CREATE_CANCELED,
27598        );
27599
27600        barrier.wait();
27601        timeout(Duration::from_secs(1), async {
27602            while !done.load(Ordering::Acquire) {
27603                tokio::task::yield_now().await;
27604            }
27605        })
27606        .await
27607        .expect("canceled blocking create did not settle");
27608        assert!(!root.join("src/late.rs").exists());
27609        std::fs::remove_dir_all(root).unwrap();
27610    }
27611}
27612
27613#[cfg(test)]
27614mod provisional_spawn_tests {
27615    use super::*;
27616    use std::sync::Arc;
27617
27618    fn shared() -> NodeShared {
27619        let catalog = active_registry().unwrap();
27620        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27621        let workspace = WorkspaceConfig::new(
27622            WorkspaceId::new("primary").unwrap(),
27623            std::env::current_dir().unwrap(),
27624        )
27625        .unwrap()
27626        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27627        NodeShared::new(
27628            handle,
27629            "fixture-token".to_owned(),
27630            NodeId::new("node-provisional-test").unwrap(),
27631            vec![workspace],
27632            vec![AgentId::new("claude").unwrap()],
27633        )
27634    }
27635
27636    /// Provisional draft map: sessionize by instance id only; 50 sequential
27637    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27638    #[test]
27639    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27640        let shared = shared();
27641        let ws = WorkspaceId::new("primary").unwrap();
27642        let mut ids = Vec::with_capacity(50);
27643        for i in 1u64..=50 {
27644            let id = AgentInstanceId(i);
27645            ids.push(id);
27646            let address = SessionAddress {
27647                workspace_id: ws.clone(),
27648                session: SessionKey {
27649                    instance_id: id,
27650                    generation: SessionGeneration(1),
27651                },
27652            };
27653            shared.mark_spawn_provisional(address);
27654        }
27655        assert_eq!(
27656            shared
27657                .provisional_spawns
27658                .lock()
27659                .unwrap_or_else(|p| p.into_inner())
27660                .len(),
27661            50
27662        );
27663        for id in ids.iter().step_by(2) {
27664            shared.sessionize_spawn(*id);
27665        }
27666        assert_eq!(
27667            shared
27668                .provisional_spawns
27669                .lock()
27670                .unwrap_or_else(|p| p.into_inner())
27671                .len(),
27672            25
27673        );
27674        assert!(shared.take_due_provisional_spawns().is_empty());
27675        {
27676            let mut map = shared
27677                .provisional_spawns
27678                .lock()
27679                .unwrap_or_else(|p| p.into_inner());
27680            let past = Instant::now() - Duration::from_secs(30);
27681            for (_, (_, at)) in map.iter_mut() {
27682                *at = past;
27683            }
27684        }
27685        let due = shared.take_due_provisional_spawns();
27686        assert_eq!(due.len(), 25);
27687        let due_ids: std::collections::BTreeSet<_> =
27688            due.iter().map(|a| a.session.instance_id.0).collect();
27689        let expected: std::collections::BTreeSet<_> =
27690            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27691        assert_eq!(due_ids, expected);
27692        assert!(shared
27693            .provisional_spawns
27694            .lock()
27695            .unwrap_or_else(|p| p.into_inner())
27696            .is_empty());
27697    }
27698
27699    /// Twenty parallel provisional marks must each get their own instance key;
27700    /// killing/sessionizing one id must not touch the others.
27701    #[test]
27702    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27703        let shared = Arc::new(shared());
27704        let ws = WorkspaceId::new("primary").unwrap();
27705        let mut handles = Vec::new();
27706        for i in 1u64..=20 {
27707            let shared = Arc::clone(&shared);
27708            let ws = ws.clone();
27709            handles.push(std::thread::spawn(move || {
27710                let id = AgentInstanceId(i);
27711                let address = SessionAddress {
27712                    workspace_id: ws,
27713                    session: SessionKey {
27714                        instance_id: id,
27715                        generation: SessionGeneration(1),
27716                    },
27717                };
27718                shared.mark_spawn_provisional(address);
27719                id
27720            }));
27721        }
27722        let mut ids = Vec::new();
27723        for h in handles {
27724            ids.push(h.join().unwrap());
27725        }
27726        ids.sort_by_key(|id| id.0);
27727        assert_eq!(ids.len(), 20);
27728        ids.dedup();
27729        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27730        assert_eq!(
27731            shared
27732                .provisional_spawns
27733                .lock()
27734                .unwrap_or_else(|p| p.into_inner())
27735                .len(),
27736            20
27737        );
27738        shared.sessionize_spawn(AgentInstanceId(7));
27739        let left: Vec<_> = shared
27740            .provisional_spawns
27741            .lock()
27742            .unwrap_or_else(|p| p.into_inner())
27743            .keys()
27744            .map(|id| id.0)
27745            .collect();
27746        assert!(!left.contains(&7));
27747        assert_eq!(left.len(), 19);
27748    }
27749}
27750