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, AgentId, AgentInstanceId, AgentSpec, ApprovalLevel,
181    CommandEnvelope, CommandId,
182    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
183    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
184    HostDecisionAuthority as ProviderHostDecisionAuthority,
185    HostRequestDecision as ProviderHostRequestDecision,
186    InputAction,
187    PromptFraming, PromptPayload, ResumeLaunchRequest,
188    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
189    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
190    ProviderSnapshot, ProviderStopReason,
191    ResumeTarget,
192    SessionGeneration, StartRequest,
193    TerminalControl, TerminalText,
194    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
195    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
196};
197use std::collections::{BTreeMap, VecDeque};
198use std::ffi::OsString;
199use std::io;
200use std::path::{Path, PathBuf};
201use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
202use std::sync::{Arc, Mutex, RwLock};
203use std::time::{Duration, Instant};
204use thiserror::Error;
205use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
206use tokio::task::{AbortHandle, JoinSet};
207use tokio::time::{sleep, timeout};
208
209mod http_api;
210pub mod node_wake;
211
212mod browser_bridge;
213
214use gate4agent_node_core::drive_loop_profile;
215
216#[path = "bundle_delivery.rs"]
217mod bundle_delivery;
218
219#[path = "context_pack_store.rs"]
220mod context_pack_store;
221
222#[cfg(windows)]
223pub use crate::platform::DEFAULT_NODE_ENDPOINT;
224
225const NODE_EVENT_HISTORY_MAX: usize = 4_096;
226const NODE_BROADCAST_CAPACITY: usize = 1_024;
227const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
228/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
229/// terminal frame is a full-screen snapshot; only the latest ever matters),
230/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
231/// two pending sends into one would silently drop content a subscriber can
232/// never recover. This needs headroom for a burst instead.
233const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
234/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
235/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
236/// admission check), and `publish_transient` sends each exactly once as a
237/// single-envelope batch -- so this many slots covers the entire pending
238/// set without ever lagging the one subscriber that matters, the harness's
239/// own connection, the way sharing the durable `event_tx`'s bus could.
240const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
241const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
242const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
243const MAX_PREAUTH_CONNECTIONS: usize = 32;
244const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
245const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
246const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
247const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
248const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
249/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
250/// record retention policy (`NodeServerConfig::session_record_retention`).
251/// A sweep cadence, not a policy value -- the policy itself lives entirely
252/// in the operator-configured age/keep-N flags; this just bounds how stale
253/// the in-memory view of dead `Unavailable` records can get between sweeps
254/// on an otherwise-idle node, mirroring the once-per-startup sweep that
255/// also runs alongside `reconcile_managed_worktrees`.
256const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
257/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
258/// field's own doc for why a spawn waiter needs this at all. Must outlive
259/// the longest a waiter can still be polling for this exact command's
260/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
261/// largest `deadline_ms` any caller may request, and the margin on top
262/// absorbs the waiter's own poll cadence plus clock skew between the drive
263/// loop (writer) and the waiter (reader).
264const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
265/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
266/// backlog of asynchronously-rejected commands nobody ever collects (a
267/// waiter that stops polling, or a burst of always-rejected commands from a
268/// misbehaving caller): once at capacity, the oldest entry is evicted to
269/// make room for the newest, mirroring the eviction rule already used by
270/// `spawn_idempotency`.
271const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
272/// How long `wait_until_removed` waits between two `Remove` re-dispatches
273/// for the same session. The presence poll runs far faster (2ms); this
274/// only paces the sending, so an accepted `Remove` still in flight is not
275/// piled on by a dozen more that land on an already-dropped instance.
276const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
277const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
278const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
279const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
280const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
281const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
282const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
283const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
284const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
285const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
286const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
287const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
288const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
289/// Default total wall-clock budget for `inspect_workspace`'s walk+git
290/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
291const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
292/// The harness relay enforces its own flat response deadline
293/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
294/// 12s) above this node-side budget. The two constants live in different
295/// crates with no shared definition, so clamp any override here comfortably
296/// below that external ceiling — a misconfigured value above it would
297/// reopen the exact deadline race this budget exists to close.
298const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
299/// Default cap on directory entries visited (not just returned) by one
300/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
301const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
302const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
303const GIT_STATUS_MAX_ENTRIES: usize = 128;
304const GIT_COMMIT_MAX_ENTRIES: usize = 12;
305const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
306const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
307const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
308const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
309const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
310const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
311const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
312const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
313const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
314const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
315    "provider-session-identity-allocation-failed";
316const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
317const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
318const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
319const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
320const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
321const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
322const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
323    "durable-state-path-semantics-unsupported";
324
325fn opaque_windows_path(value: String) -> OpaqueHostPath {
326    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
327}
328
329fn agent_progress_from_provider_snapshot(
330    address: SessionAddress,
331    provider: &ProviderSnapshot,
332) -> Option<SessionAgentProgress> {
333    let mut truncated = false;
334    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
335    let mut active_tool_labels = Vec::with_capacity(
336        provider
337            .active_tools
338            .len()
339            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
340    );
341    for tool in &provider.active_tools {
342        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
343            truncated = true;
344            break;
345        }
346        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
347            active_tool_labels.push(label);
348        }
349    }
350    let attention = provider
351        .interactions
352        .iter()
353        .find(|interaction| {
354            matches!(
355                interaction.status,
356                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
357            )
358        })
359        .map(|interaction| AgentProgressAttentionV1 {
360            kind: match interaction.interaction_kind {
361                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
362                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
363            },
364            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
365        });
366    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
367    let usage = (provider.completed_turns > 0
368        || provider.usage.input_tokens > 0
369        || provider.usage.output_tokens > 0
370        || provider.usage.cache_read_tokens > 0
371        || provider.usage.cache_write_tokens > 0
372        || provider.usage.reasoning_tokens > 0)
373        .then_some(AgentProgressUsageV1 {
374            input_tokens: provider.usage.input_tokens,
375            output_tokens: provider.usage.output_tokens,
376            cache_read_tokens: provider.usage.cache_read_tokens,
377            cache_write_tokens: provider.usage.cache_write_tokens,
378            reasoning_tokens: provider.usage.reasoning_tokens,
379        });
380    let progress = AgentProgressV1 {
381        provider_sequence: provider.sequence,
382        activity: provider.activity,
383        completed_turns: provider.completed_turns,
384        usage,
385        current: AgentProgressCurrentV1::from(provider.activity),
386        active_tool_labels,
387        active_tool_count,
388        attention,
389        subagent_count,
390        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
391        gap_count: provider.gap_count,
392        stale: provider.stale,
393        truncated,
394    };
395    let entry = SessionAgentProgress { address, progress };
396    serde_json::to_vec(&entry)
397        .ok()
398        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
399        .map(|_| entry)
400}
401
402fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
403    match u32::try_from(count) {
404        Ok(count) => count,
405        Err(_) => {
406            *truncated = true;
407            u32::MAX
408        }
409    }
410}
411
412fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
413    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
414        return None;
415    }
416    Some(match event {
417        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
418        ProviderEvent::SessionIdentityObserved { .. } => {
419            AgentProgressEventKindV1::SessionIdentityObserved
420        }
421        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
422        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
423        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
424        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
425        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
426        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
427        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
428        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
429        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
430        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
431        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
432        ProviderEvent::InteractionRequested { .. } => {
433            AgentProgressEventKindV1::InteractionRequested
434        }
435        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
436        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
437        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
438        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
439        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
440        ProviderEvent::UnrecognizedNotification { .. } => {
441            AgentProgressEventKindV1::UnrecognizedNotification
442        }
443        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
444        // ACP session/update coverage beyond text/tool/turn streaming
445        // (`plan`, `available_commands_update`, `current_mode_update`,
446        // `session_info_update`, `usage_update`, `config_option_update`,
447        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
448        // wire enum without a `#[serde(other)]` fallback; minting new
449        // variants for it is a deliberate wire-contract decision for
450        // whoever owns this protocol, not a side effect of parsing more of
451        // ACP's own wire -- until that decision is made, these fold into
452        // the same "we saw something, no dedicated kind for it yet" bucket
453        // as `UnrecognizedNotification`.
454        ProviderEvent::UserMessage { .. }
455        | ProviderEvent::Plan { .. }
456        | ProviderEvent::AvailableCommandsUpdated { .. }
457        | ProviderEvent::ModeChanged { .. }
458        | ProviderEvent::SessionInfoUpdated { .. }
459        | ProviderEvent::UsageUpdated { .. }
460        | ProviderEvent::ConfigOptionsUpdated { .. } => {
461            AgentProgressEventKindV1::UnrecognizedNotification
462        }
463    })
464}
465
466/// Maps this harness's own `HostDecisionAuthority` onto the wider
467/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
468/// chunk carries -- the four authorities a `Denied` decision is ever made
469/// under, one-for-one. The remaining `BlockAuthorityV1` variants
470/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
471/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
472/// block the PROVIDER side of the wire reported -- this function never
473/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
474/// them; the provider-side blocks below mint those directly from typed fields.
475/// Whether a provider source is a live session transport -- the only kind
476/// whose events reach the agent stream. History, resume, session-option and
477/// capability-probe sources are not sessions and stream nothing.
478fn provider_family_is_live(family: AdapterFamily) -> bool {
479    match family {
480        AdapterFamily::PtySemantic
481        | AdapterFamily::Hook
482        | AdapterFamily::ManagedHook
483        | AdapterFamily::Pipe
484        | AdapterFamily::OneShot
485        | AdapterFamily::Acp => true,
486        AdapterFamily::History
487        | AdapterFamily::Resume
488        | AdapterFamily::SessionOptions
489        | AdapterFamily::CapabilityProbe => false,
490    }
491}
492
493fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
494    match by {
495        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
496        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
497        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
498        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
499    }
500}
501
502/// The `Blocked::reason` for a `Denied` decision this build's own
503/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
504/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
505/// request_permission` request resolved by `AcpSession::
506/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
507/// neither of which computes free text (an operator answers approve/reject,
508/// never a comment; an unattended deadline asks nobody anything). `reason`
509/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
510/// -- a categorical fallback naming the authority is the
511/// honest minimum, never a placeholder standing in for detail nobody
512/// reported.
513fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
514    match authority {
515        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
516        BlockAuthorityV1::HostPolicy => "denied by host policy",
517        BlockAuthorityV1::HostDeadline => {
518            "denied by host policy after the operator deadline expired"
519        }
520        BlockAuthorityV1::Operator => "declined by the operator",
521        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
522        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
523        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
524        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
525        BlockAuthorityV1::UserRejected => "rejected by the user",
526        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
527        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
528    }
529}
530
531/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
532/// for a `HostRequestObserved` `Denied` decision.
533fn host_request_denied_block(
534    method: &str,
535    by: ProviderHostDecisionAuthority,
536    reason: Option<&str>,
537) -> (String, BlockAuthorityV1, String) {
538    let authority = block_authority(by);
539    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
540    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
541    // rather than truncating -- this cut happens here, at the producer, before
542    // the validator ever sees the text.
543    let (reason_text, _) =
544        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
545    (tool_class_label(method), authority, reason_text)
546}
547
548// ---------------------------------------------------------------------------
549// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
550// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
551// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
552// prompt` RPC error whose vendor code names a quota/limit/rate
553// exhaustion). See
554// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
555// §3 slice K2.
556// ---------------------------------------------------------------------------
557
558/// Classifies ACP's `nonExecutionKind` into the typed authority a block
559/// this build mints under, or `None` when the tool truly did not run for a
560/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
561/// TURN stopped (session closed, the operator cancelled), not that a
562/// policy/classifier/rule refused the call, so those (and any unrecognized
563/// future value) stay a plain `ToolCompleted { success: false }` only, per
564/// this plan's own rule that a tool failure is never inflated into a block
565/// this build cannot actually attribute.
566fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
567    match kind? {
568        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
569        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
570        _ => None,
571    }
572}
573
574/// Splits a provider's own denial sentence into `reason`/`help`, when it
575/// matches the fixed shape Claude Code's classifier/permission-rule denial
576/// uses -- verified live, the owner's sample fixture
577/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
578/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
579/// `authority` is NEVER derived from this text (see this module's own rule
580/// that authority comes only from a typed field) -- only `reason`/`help`
581/// are. Falls back to the full text as `reason` with no `help` when the
582/// shape does not match (a provider whose text does not carry this exact
583/// wording) -- the honest minimum: the tool truly did not run, so SOME
584/// reason is always reported, never fabricated detail beyond what arrived.
585fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
586    let trimmed = text.trim();
587    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
588        return (trimmed.to_owned(), None);
589    };
590    let tail = after_reason.trim_start();
591    let Some((reason, help)) = tail.split_once(". ") else {
592        // "Reason:" present but no ". " to split help off -- the whole
593        // tail is the reason, e.g. "Reason: Blocked by classifier." with
594        // no help text following on some providers/versions.
595        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
596        return (reason.to_owned(), None);
597    };
598    let help = help.trim();
599    if help.is_empty() {
600        (reason.trim().to_owned(), None)
601    } else {
602        (reason.trim().to_owned(), Some(help.to_owned()))
603    }
604}
605
606/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
607/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
608/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
609/// name is never threaded through to its completion). Reads the class off
610/// the guidance tail's own "add a <Class> permission rule" sentence when
611/// present (the shape the owner's sample fixture uses) -- text matching
612/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
613/// this module's own rule). Falls back to the same generic `"Tool"` class
614/// `ToolCompleted`'s own observation already uses when nothing names one.
615fn tool_class_from_denial_help(help: Option<&str>) -> String {
616    help.and_then(|text| text.split("add a ").nth(1))
617        .and_then(|tail| tail.split(' ').next())
618        .filter(|word| !word.is_empty())
619        .map(str::to_owned)
620        .unwrap_or_else(|| "Tool".to_owned())
621}
622
623/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
624/// message) names a quota/rate-limit exhaustion -- Codex's
625/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
626/// contains "limit", the word this build actually keys on; "quota"/"rate"
627/// are kept for whichever OTHER vendor's own code names one of those
628/// instead, never confirmed live, but the honest generalization of the
629/// one confirmed sample.
630fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
631    fn names_quota(s: &str) -> bool {
632        let lower = s.to_ascii_lowercase();
633        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
634    }
635    vendor_code.is_some_and(names_quota) || names_quota(message)
636}
637
638/// The `Blocked` chunk this build mints when a turn stopped via a
639/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
640/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
641/// vendor code (or message) names a quota/limit/rate exhaustion
642/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
643/// `reason_kind` = the vendor code). `None` for every other stop --
644/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
645/// `Other`, or an RPC error whose vendor code/message names none of the
646/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
647/// those, never inflated into a block this build cannot actually
648/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
649/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
650/// honest minimum names the fact that happened, never invented detail; a
651/// later slice that threads the turn's last agent text chunk through can
652/// replace it without changing this function's contract.
653fn provider_session_end_block(
654    stop_reason: &ProviderStopReason,
655) -> Option<(BlockAuthorityV1, String, String)> {
656    match stop_reason {
657        ProviderStopReason::Refusal => Some((
658            BlockAuthorityV1::ProviderRefusal,
659            "refusal".to_owned(),
660            "the provider ended the turn with stopReason: refusal".to_owned(),
661        )),
662        ProviderStopReason::ProviderError { message, vendor_code, .. }
663            if provider_error_is_quota(vendor_code.as_deref(), message) =>
664        {
665            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
666            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
667        }
668        _ => None,
669    }
670}
671
672/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
673/// `agent-stream-events-v1` channel -- see
674/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
675/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
676/// event it derives from, so a client joining the two sees one id and one
677/// ordering:
678///
679/// - `source_sequence` on the chunk is the `source_sequence` the provider
680///   event itself carries.
681/// - `InteractionPrompt::correlation_id` is minted by
682///   `correlation::interaction_correlation` with the
683///   `(instance_id, generation, provider_sequence)` triple -- never the
684///   provider's own `request_id`, whose opacity on the wire is pinned by a
685///   dedicated test.
686///
687/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
688/// (nothing else carries stream content), for a source that is not a live
689/// session transport, and for the `ProviderEvent` variants below with no
690/// declared chunk kind to carry them, or whose declared chunk kind this build
691/// has no source for yet:
692///
693/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
694///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
695///   yet, the same gap the plan names explicitly.
696/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
697///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
698///   as "what the agent is saying", so mapping the operator's words onto
699///   `Text` would misrepresent whose content it is. No dedicated
700///   user-message kind is declared, so this stays unproduced rather than
701///   mislabeled.
702///
703/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
704/// decision returns `None` (nothing for the operator to react to), a `Denied`
705/// decision returns `Some(Blocked { .. })`.
706///
707/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
708/// below (K2): each returns `None` unless
709/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
710/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
711/// RPC error naming a quota).
712fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
713    let ControlEventKind::ProviderEvent {
714        sequence: provider_sequence,
715        source,
716        source_sequence,
717        event: provider_event,
718    } = &event.event
719    else {
720        return None;
721    };
722    if !provider_family_is_live(source.family) {
723        return None;
724    }
725    let kind = match provider_event {
726        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
727            text: text.clone(),
728            is_delta: *is_delta,
729        },
730        ProviderEvent::Thinking { text } => {
731            AgentStreamChunkKindV1::Thinking { text: text.clone() }
732        }
733        ProviderEvent::InteractionRequested {
734            interaction_kind,
735            tool_name,
736            title,
737            prompt,
738            options,
739            ..
740        } => AgentStreamChunkKindV1::InteractionPrompt {
741            correlation_id: interaction_correlation(
742                event.instance_id,
743                event.generation,
744                *provider_sequence,
745            ),
746            interaction_kind: *interaction_kind,
747            tool_name: tool_name.clone(),
748            // `title`/`options` are carried straight through from
749            // `ProviderEvent::InteractionRequested`, as the agent itself
750            // gave them on `session/request_permission`'s `PermissionTool
751            // Call.title`/`options` (see `gate4agent-shell-native`'s
752            // `provider_event`, the only ACP-transport producer). A PTY- or
753            // hook-sourced interaction carries `title: None` and
754            // `options: []` from its own producer because that source has
755            // none to offer, never because this mapping dropped one.
756            title: title.clone(),
757            prompt: prompt.clone(),
758            options: options
759                .iter()
760                .map(|option| AgentStreamInteractionOptionV1 {
761                    option_id: option.option_id.clone(),
762                    name: option.name.clone(),
763                    kind: option.kind.clone(),
764                })
765                .collect(),
766        },
767        ProviderEvent::ConfigOptionsUpdated { options } => {
768            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
769        }
770        // `available` is the mode catalogue `AcpSession::available_modes()`
771        // read back at `session/new` and threaded through by
772        // `gate4agent-shell-native`'s `provider_event` (see that crate's
773        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
774        // agent's own honest "no modes announced", never "not read": ACP
775        // orders `session/new` before any `session/update`, and the
776        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
777        // exist before the same session object's catalogue is already
778        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
779        // own doc comment for the ordering argument in full).
780        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
781            current: Some(mode_id.clone()),
782            available: available
783                .iter()
784                .map(|mode| AgentStreamNamedIdV1 {
785                    id: mode.id.clone(),
786                    name: mode.name.clone(),
787                    description: mode.description.clone(),
788                })
789                .collect(),
790        },
791        // A denied host request is a block -- see this function's own doc
792        // comment for why `HostRequestObserved` is not in the exclusion list
793        // below.
794        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
795            let ProviderHostRequestDecision::Denied { by } = decision else {
796                return None;
797            };
798            let (tool_class, authority, reason_text) =
799                host_request_denied_block(method, *by, reason.as_deref());
800            AgentStreamChunkKindV1::Blocked {
801                correlation_id: None,
802                tool_class,
803                authority,
804                reason_kind: None,
805                reason: reason_text,
806                help: None,
807            }
808        }
809        // Same relationship as `HostRequestObserved` above, driven by ACP's
810        // own `_meta.nonExecutionKind` instead of the host's
811        // `HostRequestDecision`.
812        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
813            let (authority, reason_kind) =
814                provider_tool_block_authority(non_execution_kind.as_deref())?;
815            let (reason, help) = split_provider_denial_text(output);
816            let (reason, _) =
817                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
818            let help = help.map(|help| {
819                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
820            });
821            AgentStreamChunkKindV1::Blocked {
822                correlation_id: Some(tool_correlation(event, source, id)),
823                tool_class: tool_class_from_denial_help(help.as_deref()),
824                authority,
825                reason_kind: Some(reason_kind.to_owned()),
826                reason,
827                help,
828            }
829        }
830        // Same relationship, driven by `stopReason: "refusal"` or a
831        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
832        // instead of a tool-level fact.
833        ProviderEvent::SessionEnded { stop_reason, .. } => {
834            let (authority, reason_kind, reason) =
835                stop_reason.as_ref().and_then(provider_session_end_block)?;
836            let (reason, _) =
837                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
838            AgentStreamChunkKindV1::Blocked {
839                correlation_id: None,
840                tool_class: "Session".to_owned(),
841                authority,
842                reason_kind: Some(reason_kind),
843                reason,
844                help: None,
845            }
846        }
847        ProviderEvent::SessionStarted { .. }
848        | ProviderEvent::SessionIdentityObserved { .. }
849        | ProviderEvent::TurnStarted { .. }
850        | ProviderEvent::WorkingObserved
851        | ProviderEvent::ToolStarted { .. }
852        | ProviderEvent::TurnCompleted { .. }
853        | ProviderEvent::ContextWindowUsage { .. }
854        | ProviderEvent::TurnInterrupted
855        | ProviderEvent::Error { .. }
856        | ProviderEvent::Ready
857        | ProviderEvent::InteractionResolved { .. }
858        | ProviderEvent::SubagentStarted { .. }
859        | ProviderEvent::SubagentStopped { .. }
860        | ProviderEvent::RateLimited { .. }
861        | ProviderEvent::UnrecognizedNotification { .. }
862        | ProviderEvent::UserMessage { .. }
863        | ProviderEvent::Plan { .. }
864        | ProviderEvent::AvailableCommandsUpdated { .. }
865        | ProviderEvent::SessionInfoUpdated { .. }
866        | ProviderEvent::UsageUpdated { .. } => return None,
867    };
868    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
869}
870
871async fn run_native_session_catalog_operation<T, E, F>(
872    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
873    operation: &'static str,
874    execute: F,
875) -> Result<T, NodeFailure>
876where
877    T: Send + 'static,
878    E: std::fmt::Display + Send + 'static,
879    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
880{
881    let task = tokio::task::spawn_blocking(move || {
882        let mut catalog = match catalog.try_lock() {
883            Ok(catalog) => catalog,
884            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
885            Err(std::sync::TryLockError::WouldBlock) => {
886                return Err(failure(
887                    NodeFailureCode::BackendBusy,
888                    "native session catalog operation is already running",
889                ));
890            }
891        };
892        execute(&mut catalog).map_err(|error| {
893            failure(
894                NodeFailureCode::BackendOperationFailed,
895                &format!("{operation} failed: {error}"),
896            )
897        })
898    });
899    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
900        .await
901        .map_err(|_| {
902            failure(
903                NodeFailureCode::BackendBusy,
904                &format!("{operation} exceeded its bounded deadline"),
905            )
906        })?
907        .map_err(|_| {
908            failure(
909                NodeFailureCode::BackendOperationFailed,
910                &format!("{operation} task failed"),
911            )
912        })?
913}
914
915fn windows_path_text(path: &OpaqueHostPath) -> &str {
916    path.as_utf8()
917        .expect("Windows node only creates UTF-8 opaque host paths")
918}
919
920fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
921    path.as_utf8()
922        .map(str::to_owned)
923        .ok_or_else(|| failure(
924            NodeFailureCode::InvalidRequest,
925            "this node requires UTF-8 host paths",
926        ))
927}
928
929fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
930    GitWorktreeSnapshot {
931        path: opaque_windows_path(worktree.path),
932        head: worktree.head,
933        branch: worktree.branch,
934        is_bare: worktree.is_bare,
935        is_main: worktree.is_main,
936        locked: worktree.locked,
937        lock_reason: worktree.lock_reason,
938        prunable: worktree.prunable,
939        prunable_reason: worktree.prunable_reason,
940        workspace_id: worktree.workspace_id.take(),
941    }
942}
943
944#[derive(Clone, Debug, Eq, PartialEq)]
945pub struct WorkspaceConfig {
946    workspace_id: WorkspaceId,
947    canonical_root: String,
948    worktree_service_mode: WorktreeServiceMode,
949    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
950}
951
952impl WorkspaceConfig {
953    pub fn new(
954        workspace_id: WorkspaceId,
955        root: impl AsRef<Path>,
956    ) -> Result<Self, NodeServerError> {
957        let root = root.as_ref();
958        if !root.is_absolute() {
959            return Err(NodeServerError::InvalidWorkspaceRoot {
960                workspace_id,
961                path: root.to_string_lossy().into_owned(),
962                message: "path is not absolute".to_owned(),
963            });
964        }
965        let canonical = std::fs::canonicalize(root).map_err(|error| {
966            NodeServerError::InvalidWorkspaceRoot {
967                workspace_id: workspace_id.clone(),
968                path: root.to_string_lossy().into_owned(),
969                message: error.to_string(),
970            }
971        })?;
972        if !canonical.is_dir() {
973            return Err(NodeServerError::InvalidWorkspaceRoot {
974                workspace_id,
975                path: root.to_string_lossy().into_owned(),
976                message: "path is not a directory".to_owned(),
977            });
978        }
979        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
980            NodeServerError::InvalidWorkspaceRoot {
981                workspace_id: workspace_id.clone(),
982                path: path.to_string_lossy().into_owned(),
983                message: "canonical path is not valid Unicode".to_owned(),
984            }
985        })?;
986        let canonical_root = platform::normalize_canonical_root(canonical_root);
987        validate_workspace_root(&workspace_id, &canonical_root)?;
988        Ok(Self {
989            workspace_id,
990            canonical_root,
991            worktree_service_mode: WorktreeServiceMode::Manual,
992            managed_worktree_profiles: BTreeMap::new(),
993        })
994    }
995
996    pub fn workspace_id(&self) -> &WorkspaceId {
997        &self.workspace_id
998    }
999
1000    pub fn canonical_root(&self) -> &str {
1001        &self.canonical_root
1002    }
1003
1004    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1005        self.worktree_service_mode = mode;
1006        self
1007    }
1008
1009    pub fn with_managed_worktree_profile(
1010        mut self,
1011        profile: ManagedWorktreeProfile,
1012    ) -> Result<Self, NodeServerError> {
1013        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1014            NodeServerError::InvalidManagedWorktreeProfile {
1015                workspace_id: self.workspace_id.clone(),
1016                profile_id: profile.profile_id().clone(),
1017                message,
1018            }
1019        })?;
1020        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1021            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1022                workspace_id: self.workspace_id,
1023                profile_id: profile.profile_id().clone(),
1024            });
1025        }
1026        if self.managed_worktree_profiles.len()
1027            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1028        {
1029            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1030                workspace_id: self.workspace_id,
1031                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1032            });
1033        }
1034        self.managed_worktree_profiles
1035            .insert(profile.profile_id().clone(), profile);
1036        Ok(self)
1037    }
1038}
1039
1040#[derive(Clone)]
1041pub struct NodeServerConfig {
1042    pub endpoint: String,
1043    api_listen: Option<std::net::SocketAddr>,
1044    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1045    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1046    bridge_listen: Option<std::net::SocketAddr>,
1047    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1048    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1049    bridge_token: Option<String>,
1050    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1051    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1052    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1053    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1054    /// node purely a listener, which is what it has always been.
1055    call_home: Option<std::net::SocketAddr>,
1056    /// When set, call-home authenticates by signing the relay's challenge with this
1057    /// identity (enrolled node) and falls back to the token handshake against a relay
1058    /// that sends no challenge.
1059    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1060    pub node_id: NodeId,
1061    pub workspaces: Vec<WorkspaceConfig>,
1062    access_token: String,
1063    pub runtime: NativeRuntimeConfig,
1064    session_record_retention: SessionRecordRetentionConfig,
1065    state_path: Option<PathBuf>,
1066    spawn_profiles: SpawnProfileRegistry,
1067    session_environment: Option<NodeSessionEnvironmentConfig>,
1068    history: Option<NativeHistoryConfig>,
1069    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1070    /// Station network allowlist catalog (opaque ids). Empty default.
1071    /// Loaded at startup from `--network-allowlist-catalog` / env
1072    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1073    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1074    #[cfg(feature = "fixture")]
1075    fixture_raw_pty_runtime: bool,
1076}
1077
1078#[derive(Clone)]
1079struct NodeSessionEnvironmentConfig {
1080    root: PathBuf,
1081    resolver: Arc<dyn NodeSecretResolver>,
1082}
1083
1084impl NodeServerConfig {
1085    pub fn new(
1086        endpoint: impl Into<String>,
1087        access_token: impl Into<String>,
1088        node_id: NodeId,
1089        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1090    ) -> Result<Self, NodeServerError> {
1091        let endpoint = endpoint.into();
1092        let access_token = access_token.into();
1093        if !platform::validate_endpoint(&endpoint) {
1094            return Err(NodeServerError::InvalidEndpoint);
1095        }
1096        if access_token.is_empty() || access_token.len() > 4_096 {
1097            return Err(NodeServerError::InvalidAccessToken);
1098        }
1099        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1100        if workspaces.is_empty() {
1101            return Err(NodeServerError::NoWorkspaces);
1102        }
1103        let mut workspace_ids = BTreeMap::new();
1104        let mut workspace_roots = BTreeMap::new();
1105        for workspace in &workspaces {
1106            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1107                && workspace.managed_worktree_profiles.is_empty()
1108            {
1109                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1110                    workspace.workspace_id.clone(),
1111                ));
1112            }
1113            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1114                && !workspace.managed_worktree_profiles.is_empty()
1115            {
1116                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1117                    workspace.workspace_id.clone(),
1118                ));
1119            }
1120            if workspace_ids
1121                .insert(workspace.workspace_id.clone(), ())
1122                .is_some()
1123            {
1124                return Err(NodeServerError::DuplicateWorkspaceId(
1125                    workspace.workspace_id.clone(),
1126                ));
1127            }
1128            let root_key = platform::root_identity(&workspace.canonical_root);
1129            if let Some(existing) = workspace_roots.insert(
1130                root_key,
1131                workspace.workspace_id.clone(),
1132            ) {
1133                return Err(NodeServerError::DuplicateWorkspaceRoot {
1134                    first: existing,
1135                    second: workspace.workspace_id.clone(),
1136                    root: workspace.canonical_root.clone(),
1137                });
1138            }
1139        }
1140        Ok(Self {
1141            endpoint,
1142            api_listen: None,
1143            bridge_listen: None,
1144            bridge_token: None,
1145            bridge_underlay: None,
1146            call_home: None,
1147            call_home_identity: None,
1148            node_id,
1149            workspaces,
1150            access_token,
1151            runtime: NativeRuntimeConfig::default(),
1152            session_record_retention: SessionRecordRetentionConfig::default(),
1153            state_path: None,
1154            spawn_profiles: SpawnProfileRegistry::default(),
1155            session_environment: None,
1156            history: None,
1157            harness_mcp_helper: None,
1158            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1159            #[cfg(feature = "fixture")]
1160            fixture_raw_pty_runtime: false,
1161        })
1162    }
1163
1164    pub fn with_api_listen(
1165        mut self,
1166        api_listen: std::net::SocketAddr,
1167    ) -> Result<Self, NodeServerError> {
1168        if !api_listen.ip().is_loopback() {
1169            return Err(NodeServerError::InvalidApiListen(api_listen));
1170        }
1171        self.api_listen = Some(api_listen);
1172        Ok(self)
1173    }
1174
1175    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1176    /// as `with_api_listen`). Does not enable by default — libraries and the
1177    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1178    pub fn with_bridge_listen(
1179        mut self,
1180        bridge_listen: std::net::SocketAddr,
1181    ) -> Result<Self, NodeServerError> {
1182        if !bridge_listen.ip().is_loopback() {
1183            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1184        }
1185        self.bridge_listen = Some(bridge_listen);
1186        Ok(self)
1187    }
1188
1189    /// Optional bridge shared secret, distinct from the C2/node access token.
1190    /// Never log the value. Empty / oversize refused.
1191    pub fn with_bridge_token(
1192        mut self,
1193        bridge_token: impl Into<String>,
1194    ) -> Result<Self, NodeServerError> {
1195        let bridge_token = bridge_token.into();
1196        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1197            return Err(NodeServerError::InvalidBridgeToken);
1198        }
1199        self.bridge_token = Some(bridge_token);
1200        Ok(self)
1201    }
1202
1203    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1204    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1205    /// already configured. Transport key is 32 raw bytes (never logged).
1206    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1207    pub fn with_bridge_underlay(
1208        mut self,
1209        bind: std::net::SocketAddr,
1210        transport_key: [u8; 32],
1211        underlay_token: impl Into<String>,
1212    ) -> Result<Self, NodeServerError> {
1213        if self.bridge_listen.is_none() {
1214            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1215        }
1216        if self.bridge_token.is_none() {
1217            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1218        }
1219        let underlay_token = underlay_token.into();
1220        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1221            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1222        }
1223        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1224        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1225            bind,
1226            transport_key,
1227            underlay_token,
1228        });
1229        Ok(self)
1230    }
1231
1232    /// Also dial `relay` and serve this wire over the connection, in
1233    /// addition to accepting on the local endpoint.
1234    ///
1235    /// Who opens the socket and who is the protocol's server are separate
1236    /// questions, and this is the node's half of separating them. The node
1237    /// stays the server either way -- it answers `NodeRequest`s and emits
1238    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1239    /// that changes is that a node with no address anyone can reach makes
1240    /// the connection itself, and names itself once it arrives (see
1241    /// `gate4agent_node_wire::write_call_home_announce`).
1242    ///
1243    /// Loopback only, and that is not an oversight to be flagged and
1244    /// worked around. This wire carries terminal contents, keystrokes and
1245    /// file bytes as plaintext JSON; its mutual challenge-response
1246    /// authenticates both ends and encrypts nothing. Every listener in
1247    /// this stack is loopback-guarded for that reason, and a dialler must
1248    /// hold the same line -- a node calling a public relay would put the
1249    /// whole control plane on the wire in the clear. Reaching a relay on
1250    /// another host is a transport question, and it stays unanswered here
1251    /// on purpose: this change is about direction, not distance.
1252    pub fn with_call_home(
1253        mut self,
1254        relay: std::net::SocketAddr,
1255    ) -> Result<Self, NodeServerError> {
1256        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1257        // and port 0 refused — plaintext wire must not dial every interface.
1258        if relay.port() == 0 || relay.ip().is_unspecified() {
1259            return Err(NodeServerError::InvalidCallHome(relay));
1260        }
1261        self.call_home = Some(relay);
1262        Ok(self)
1263    }
1264
1265    /// Authenticate call-home with a node-held identity instead of a standing token.
1266    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1267        self.call_home_identity = Some(identity);
1268        self
1269    }
1270
1271    pub fn with_state_path(
1272        mut self,
1273        state_path: impl Into<PathBuf>,
1274    ) -> Result<Self, NodeServerError> {
1275        let state_path = state_path.into();
1276        if !state_path.is_absolute() || state_path.file_name().is_none() {
1277            return Err(NodeServerError::InvalidStatePath(
1278                state_path.to_string_lossy().into_owned(),
1279            ));
1280        }
1281        self.state_path = Some(state_path);
1282        Ok(self)
1283    }
1284
1285    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1286        self.spawn_profiles = spawn_profiles;
1287        self
1288    }
1289
1290    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1291    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1292    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1293    /// when this is never called -- a node never deletes a durable record
1294    /// until an operator opts in with a real value.
1295    pub fn with_session_record_retention(
1296        mut self,
1297        retention: SessionRecordRetentionConfig,
1298    ) -> Self {
1299        self.session_record_retention = retention;
1300        self
1301    }
1302
1303    pub fn with_session_environment_materialization(
1304        mut self,
1305        root: impl Into<PathBuf>,
1306        resolver: Arc<dyn NodeSecretResolver>,
1307    ) -> Result<Self, NodeServerError> {
1308        let root = root.into();
1309        if !root.is_absolute() {
1310            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1311        }
1312        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1313        Ok(self)
1314    }
1315
1316    /// Enables bounded provider-history discovery and loading from explicit
1317    /// host-owned roots. Ambient vendor homes are never inferred here.
1318    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1319        self.history = Some(history);
1320        self
1321    }
1322
1323    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1324    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1325    /// Both platforms review the helper the same way (absolute regular file +
1326    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1327    /// gate after Unix listener/peer-cred support landed.
1328    pub fn with_harness_mcp_helper(
1329        mut self,
1330        helper_program: impl Into<PathBuf>,
1331    ) -> Result<Self, NodeServerError> {
1332        self.harness_mcp_helper = Some(
1333            ReviewedHarnessMcpProgram::review(helper_program.into())
1334                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1335        );
1336        Ok(self)
1337    }
1338
1339    /// Install an in-memory station network allowlist catalog (opaque ids +
1340    /// optional node-local permits / provider-native mapping). Empty remains
1341    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1342    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1343    pub fn with_network_allowlist_catalog(
1344        mut self,
1345        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1346    ) -> Result<Self, NodeServerError> {
1347        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1348            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1349                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1350            });
1351        }
1352        self.network_allowlist_catalog = catalog;
1353        Ok(self)
1354    }
1355
1356    /// Load station network allowlist catalog from an absolute regular file
1357    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1358    /// that want env fallback use `resolve_network_allowlist_catalog`.
1359    pub fn with_network_allowlist_catalog_file(
1360        self,
1361        path: impl AsRef<std::path::Path>,
1362    ) -> Result<Self, NodeServerError> {
1363        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1364            .map_err(NodeServerError::from)?;
1365        self.with_network_allowlist_catalog(catalog)
1366    }
1367}
1368
1369pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1370    platform::default_state_path(node_id.as_str())
1371        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1372}
1373
1374pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1375    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1376}
1377
1378fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1379    let parent = state_path.parent()?;
1380    let mut name = state_path.file_name()?.to_os_string();
1381    name.push(".delivery-store");
1382    Some(parent.join(name))
1383}
1384
1385fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1386    let parent = state_path.parent()?;
1387    let mut name = state_path.file_name()?.to_os_string();
1388    name.push(".context-pack-store");
1389    Some(parent.join(name))
1390}
1391
1392pub struct NodeServer {
1393    config: NodeServerConfig,
1394    runtime: NativeRuntime,
1395    shared: Arc<NodeShared>,
1396    events: EventSubscription,
1397    state_path_lock: Option<session_registry::StatePathLock>,
1398}
1399
1400#[cfg(feature = "fixture")]
1401#[derive(Clone, Default)]
1402pub struct SpawnManagedWorktreeV2FailureProbe {
1403    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1404}
1405
1406#[cfg(feature = "fixture")]
1407impl SpawnManagedWorktreeV2FailureProbe {
1408    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1409        *self
1410            .latest_code
1411            .lock()
1412            .unwrap_or_else(|poisoned| poisoned.into_inner())
1413    }
1414
1415    fn record(&self, code: Option<NodeFailureCode>) {
1416        *self
1417            .latest_code
1418            .lock()
1419            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1420    }
1421}
1422
1423#[cfg(all(feature = "fixture", windows))]
1424struct IsolatedCodexHomeFixtureEnvironment;
1425
1426#[cfg(all(feature = "fixture", windows))]
1427impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1428    for IsolatedCodexHomeFixtureEnvironment
1429{
1430    fn resolve_child_environment(
1431        &self,
1432    ) -> Result<
1433        Vec<gate4agent_catalog::EnvMutation>,
1434        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1435    > {
1436        Ok(vec![gate4agent_catalog::EnvMutation {
1437            key: OsString::from("USERPROFILE"),
1438            value: None,
1439        }])
1440    }
1441}
1442
1443impl NodeServer {
1444    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1445    /// must present.
1446    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1447        self.shared.m4a_accounts.as_ref()?;
1448        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1449    }
1450
1451    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1452        let catalog = active_registry()?;
1453        Self::new_with_registry(config, catalog)
1454    }
1455
1456    #[cfg(feature = "fixture")]
1457    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1458        let mut spec = gate4agent_testkit::interactive_agent_spec();
1459        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1460        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1461        Self::new_fixture_with_spec(config, spec)
1462    }
1463
1464    #[cfg(feature = "fixture")]
1465    pub fn new_clean_exit_fixture(
1466        config: NodeServerConfig,
1467        fixture_root: PathBuf,
1468        started_marker: PathBuf,
1469        release_signal: PathBuf,
1470    ) -> Result<Self, NodeServerError> {
1471        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1472            &fixture_root,
1473            &started_marker,
1474            &release_signal,
1475        )
1476        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1477        spec.id = AgentId::new("claude")
1478            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1479        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1480        Self::new_fixture_with_spec(config, spec)
1481    }
1482
1483    /// A `claude` source whose session establishes a verified provider
1484    /// identity via an early `SessionStart` Hook post (so it is eligible for
1485    /// auto-export-at-exit and its native history is independently
1486    /// loadable), waits for `release_signal`, then exits cleanly — paired
1487    /// on the same Node with a plain `codex` target that never validates
1488    /// anything about what it receives. Built for A2 durable ContextPack
1489    /// E2E coverage: the test itself inspects materialized files and
1490    /// managed-session-record fields directly, rather than having the
1491    /// target script self-validate.
1492    #[cfg(all(feature = "fixture", windows))]
1493    pub fn new_durable_context_pack_clean_exit_fixture(
1494        config: NodeServerConfig,
1495        fixture_root: PathBuf,
1496        started_marker: PathBuf,
1497        release_signal: PathBuf,
1498        session_id: &str,
1499    ) -> Result<Self, NodeServerError> {
1500        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1501            &fixture_root,
1502            &started_marker,
1503            &release_signal,
1504            session_id,
1505        )
1506        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1507        let source_id = AgentId::new("claude")
1508            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1509        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1510            NodeServerError::Registry(
1511                "durable context pack clean-exit fixture source adapter is unavailable"
1512                    .to_owned(),
1513            )
1514        })?;
1515        source_spec.capabilities.adapters.history =
1516            source_provider.capabilities.adapters.history.clone();
1517        source_spec.id = source_id;
1518        source_spec.display_name =
1519            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1520        // Both specs launch via `powershell.exe`; the registry's ambiguity
1521        // check keys on `detection.command` (+ aliases), not `launch.program`
1522        // -- give each a distinct detection identity, same precedent as
1523        // `context_pack_fixture_catalog`'s per-provider
1524        // `gate4agent-{provider}-context-fixture.cmd` commands.
1525        source_spec.detection.command =
1526            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1527        source_spec.detection.aliases.clear();
1528
1529        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1530        target_spec.id = AgentId::new("codex")
1531            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1532        target_spec.display_name =
1533            "Controlled Codex durable context-pack target fixture".to_owned();
1534        target_spec.detection.command =
1535            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1536        target_spec.detection.aliases.clear();
1537
1538        let catalog = AgentRegistry::new([source_spec, target_spec])
1539            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1540        let mut server = Self::new_with_registry(config, catalog)?;
1541        Arc::get_mut(&mut server.shared)
1542            .expect("fixture Node shared state must remain uniquely owned before run")
1543            .fixture_semantic_hook_policy = true;
1544        Ok(server)
1545    }
1546
1547    #[cfg(feature = "fixture")]
1548    pub fn new_monitoring_hook_fixture(
1549        config: NodeServerConfig,
1550    ) -> Result<Self, NodeServerError> {
1551        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1552        spec.id = AgentId::new("claude")
1553            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1554        let mut server = Self::new_fixture_with_spec(config, spec)?;
1555        Arc::get_mut(&mut server.shared)
1556            .expect("fixture Node shared state must remain uniquely owned before run")
1557            .fixture_semantic_hook_policy = true;
1558        Ok(server)
1559    }
1560
1561    #[cfg(all(feature = "fixture", windows))]
1562    pub fn new_provider_bundle_argv_fixture(
1563        config: NodeServerConfig,
1564        agent_id: AgentId,
1565        proof_path: PathBuf,
1566    ) -> Result<Self, NodeServerError> {
1567        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1568        Self::new_fixture_with_spec(config, spec)
1569    }
1570
1571    #[cfg(all(feature = "fixture", windows))]
1572    pub fn new_provider_bundle_argv_hold_fixture(
1573        config: NodeServerConfig,
1574        agent_id: AgentId,
1575        proof_path: PathBuf,
1576        release_signal: PathBuf,
1577    ) -> Result<Self, NodeServerError> {
1578        let spec = Self::provider_bundle_argv_fixture_spec(
1579            agent_id,
1580            proof_path,
1581            Some(release_signal),
1582        )?;
1583        Self::new_fixture_with_spec(config, spec)
1584    }
1585
1586    #[cfg(all(feature = "fixture", windows))]
1587    fn provider_bundle_argv_fixture_spec(
1588        agent_id: AgentId,
1589        proof_path: PathBuf,
1590        release_signal: Option<PathBuf>,
1591    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1592        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1593            return Err(NodeServerError::Registry(
1594                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1595            ));
1596        }
1597        let mut spec = gate4agent_testkit::interactive_agent_spec();
1598        spec.id = agent_id.clone();
1599        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1600        let provider = builtin_registry()
1601            .get(&agent_id)
1602            .ok_or_else(|| {
1603                NodeServerError::Registry(
1604                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1605                )
1606            })?;
1607        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1608        if !proof_path.is_absolute()
1609            || !proof_path
1610                .parent()
1611                .is_some_and(|parent| parent.is_dir())
1612        {
1613            return Err(NodeServerError::Registry(
1614                "provider bundle argv proof path requires an existing absolute parent"
1615                    .to_owned(),
1616            ));
1617        }
1618        if let Some(release_signal) = release_signal.as_ref() {
1619            if !release_signal.is_absolute()
1620                || !release_signal
1621                    .parent()
1622                    .is_some_and(|parent| parent.is_dir())
1623            {
1624                return Err(NodeServerError::Registry(
1625                    "provider bundle argv release path requires an existing absolute parent"
1626                        .to_owned(),
1627                ));
1628            }
1629            if release_signal.exists() {
1630                return Err(NodeServerError::Registry(
1631                    "provider bundle argv release path must not exist".to_owned(),
1632                ));
1633            }
1634            let proof_parent = std::fs::canonicalize(
1635                proof_path
1636                    .parent()
1637                    .expect("validated provider bundle proof parent"),
1638            )
1639            .map_err(|_| {
1640                NodeServerError::Registry(
1641                    "provider bundle argv proof parent is unavailable".to_owned(),
1642                )
1643            })?;
1644            let release_parent = std::fs::canonicalize(
1645                release_signal
1646                    .parent()
1647                    .expect("validated provider bundle release parent"),
1648            )
1649            .map_err(|_| {
1650                NodeServerError::Registry(
1651                    "provider bundle argv release parent is unavailable".to_owned(),
1652                )
1653            })?;
1654            if proof_parent != release_parent {
1655                return Err(NodeServerError::Registry(
1656                    "provider bundle argv release path must share the proof parent".to_owned(),
1657                ));
1658            }
1659        }
1660        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1661            NodeServerError::Registry(
1662                "provider bundle argv proof path is not valid Unicode".to_owned(),
1663            )
1664        })?;
1665        let release_signal = release_signal.map(|release_signal| {
1666            release_signal.into_os_string().into_string().map_err(|_| {
1667                NodeServerError::Registry(
1668                    "provider bundle argv release path is not valid Unicode".to_owned(),
1669                )
1670            })
1671        }).transpose()?;
1672        let script = spec
1673            .launch
1674            .fixed_args
1675            .pop()
1676            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1677        let bundle_validation = if agent_id.as_str() == "codex" {
1678            "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') }"
1679        } else if agent_id.as_str() == "claude" {
1680            "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 }"
1681        } else {
1682            "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 }"
1683        };
1684        if let Some(release_signal) = release_signal {
1685            spec.launch.fixed_args.push(format!(
1686                "& {{ 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} }}",
1687            ));
1688            spec.launch.fixed_args.push(proof_path);
1689            spec.launch.fixed_args.push(release_signal);
1690        } else {
1691            spec.launch.fixed_args.push(format!(
1692                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1693            ));
1694            spec.launch.fixed_args.push(proof_path);
1695        }
1696        Ok(spec)
1697    }
1698
1699    #[cfg(all(feature = "fixture", windows))]
1700    pub fn new_context_pack_fixture(
1701        config: NodeServerConfig,
1702        proof_path: PathBuf,
1703    ) -> Result<Self, NodeServerError> {
1704        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1705        Self::new_with_registry(config, catalog)
1706    }
1707
1708    #[cfg(all(feature = "fixture", windows))]
1709    pub fn new_monitoring_context_pack_fixture(
1710        config: NodeServerConfig,
1711        proof_path: PathBuf,
1712    ) -> Result<Self, NodeServerError> {
1713        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1714        let mut server = Self::new_with_registry(config, catalog)?;
1715        Arc::get_mut(&mut server.shared)
1716            .expect("fixture Node shared state must remain uniquely owned before run")
1717            .fixture_semantic_hook_policy = true;
1718        Ok(server)
1719    }
1720
1721    #[cfg(all(feature = "fixture", windows))]
1722    pub fn install_isolated_codex_home_environment_fixture(
1723        &mut self,
1724        profile_id: SpawnEnvironmentProfileId,
1725        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1726    ) -> Result<(), NodeServerError> {
1727        let provider = AgentId::new("codex")
1728            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1729        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1730            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1731                "{}-pty",
1732                profile_id.as_str(),
1733            ))
1734            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1735            provider.clone(),
1736            TransportKind::Pty,
1737            vec![OsString::from("USERPROFILE")],
1738            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1739        )
1740        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1741        let materialization = NodeSessionMaterializationProfile::new(
1742            Vec::new(),
1743            vec![crate::session_environment::NodeSessionPathBinding::new(
1744                "CODEX_HOME",
1745                crate::session_environment::NodeSessionPathClass::ProviderHome,
1746            )
1747            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1748            Vec::new(),
1749        )
1750        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1751        self.install_environment_profile(
1752            NodeEnvironmentProfile::new_with_materialization(
1753                profile_id,
1754                profile_revision,
1755                provider,
1756                [native_profile],
1757                Some(materialization),
1758            )
1759            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1760        )
1761    }
1762
1763    #[cfg(all(feature = "fixture", windows))]
1764    pub fn new_context_only_proof_fixture(
1765        config: NodeServerConfig,
1766        target_provider: AgentId,
1767        proof_path: PathBuf,
1768    ) -> Result<Self, NodeServerError> {
1769        if target_provider.as_str() != "kimi" {
1770            return Err(NodeServerError::Registry(
1771                "context-only proof fixture requires Kimi".to_owned(),
1772            ));
1773        }
1774        let catalog = Self::context_pack_fixture_catalog(
1775            proof_path,
1776            Some(&target_provider),
1777            false,
1778        )?;
1779        Self::new_with_registry(config, catalog)
1780    }
1781
1782    #[cfg(all(feature = "fixture", windows))]
1783    fn context_pack_fixture_catalog(
1784        proof_path: PathBuf,
1785        context_only_target: Option<&AgentId>,
1786        monitoring_claude_source: bool,
1787    ) -> Result<AgentRegistry, NodeServerError> {
1788        if !proof_path.is_absolute()
1789            || !proof_path
1790                .parent()
1791                .is_some_and(|parent| parent.is_dir())
1792        {
1793            return Err(NodeServerError::Registry(
1794                "context pack proof path requires an existing absolute parent".to_owned(),
1795            ));
1796        }
1797        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1798            NodeServerError::Registry(
1799                "context pack proof path is not valid Unicode".to_owned(),
1800            )
1801        })?;
1802        let providers = ["claude", "codex", "grok", "kimi"];
1803        let builtins = builtin_registry();
1804        let mut specs = Vec::with_capacity(providers.len());
1805        for provider_id in providers {
1806            let agent_id = AgentId::new(provider_id)
1807                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1808            let provider = builtins.get(&agent_id).ok_or_else(|| {
1809                NodeServerError::Registry(
1810                    "context pack fixture provider adapter is unavailable".to_owned(),
1811                )
1812            })?;
1813            let monitored_provider =
1814                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1815            let mut spec = if monitored_provider {
1816                gate4agent_testkit::monitoring_hook_agent_spec()
1817            } else {
1818                gate4agent_testkit::interactive_agent_spec()
1819            };
1820            spec.id = agent_id.clone();
1821            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1822            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1823            spec.detection.aliases.clear();
1824            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1825            if provider_id == "codex" {
1826                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1827                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1828                })?;
1829                let validation = r#"
1830$bundleArgs = @($bundleArgs)
1831if ($bundleArgs.Count -ne 0) { exit 91 }
1832$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1833$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1834$cwd = (Get-Location).ProviderPath
1835if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1836$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1837if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1838$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1839if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1840$sha = [Security.Cryptography.SHA256]::Create()
1841try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1842if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1843if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1844$contextPath = Join-Path $contextRoot 'context-pack.json'
1845if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1846$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1847if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1848$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1849$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1850$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1851$separator = [IO.Path]::DirectorySeparatorChar
1852if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1853if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1854$rawContext = [IO.File]::ReadAllText($contextPath)
1855if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1856try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1857$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1858$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1859foreach ($property in @($document.PSObject.Properties.Name)) {
1860    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1861}
1862foreach ($property in $requiredTopLevel) {
1863    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1864}
1865if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1866if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1867$messages = @($document.retained_messages)
1868if ($messages.Count -eq 0) { exit 107 }
1869foreach ($message in $messages) {
1870    $messageProperties = @($message.PSObject.Properties.Name)
1871    foreach ($property in $messageProperties) {
1872        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1873    }
1874    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1875}
1876$sha = [Security.Cryptography.SHA256]::Create()
1877try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1878[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))
1879$rawProof = [IO.File]::ReadAllText($proofPath)
1880if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1881[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1882"#;
1883                spec.launch.fixed_args.push(format!(
1884                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1885                ));
1886                spec.launch.fixed_args.push(proof_path.clone());
1887            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1888                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1889                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1890                })?;
1891                let validation = r#"
1892$bundleArgs = @($bundleArgs)
1893if ($bundleArgs.Count -ne 0) { exit 91 }
1894$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1895$cwd = (Get-Location).ProviderPath
1896if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1897$contextPath = Join-Path $contextRoot 'context-pack.json'
1898if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1899$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1900if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1901$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1902$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1903$separator = [IO.Path]::DirectorySeparatorChar
1904if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1905try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1906if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1907if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1908$messages = @($document.retained_messages)
1909if ($messages.Count -eq 0) { exit 99 }
1910$hasUser = $false
1911$hasAssistant = $false
1912foreach ($message in $messages) {
1913    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1914    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1915    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1916    else { exit 101 }
1917}
1918if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1919$sha = [Security.Cryptography.SHA256]::Create()
1920try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1921[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1922[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1923"#;
1924                spec.launch.fixed_args.push(format!(
1925                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1926                ));
1927                spec.launch.fixed_args.push(proof_path.clone());
1928            }
1929            specs.push(spec);
1930        }
1931        let catalog = AgentRegistry::new(specs)
1932            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1933        Ok(catalog)
1934    }
1935
1936    #[cfg(feature = "fixture")]
1937    pub fn new_exact_launcher_fixture(
1938        config: NodeServerConfig,
1939        agent_id: AgentId,
1940        launcher: String,
1941    ) -> Result<Self, NodeServerError> {
1942        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1943            return Err(NodeServerError::Registry(
1944                "exact launcher fixture requires a supported provider".to_owned(),
1945            ));
1946        }
1947        if launcher.contains('\0') {
1948            return Err(NodeServerError::Registry(
1949                "exact launcher fixture path contains NUL".to_owned(),
1950            ));
1951        }
1952        let launcher_path = Path::new(&launcher);
1953        if !launcher_path.is_absolute() {
1954            return Err(NodeServerError::Registry(
1955                "exact launcher fixture path must be absolute".to_owned(),
1956            ));
1957        }
1958        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1959            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1960        })?;
1961        if !metadata.is_file() {
1962            return Err(NodeServerError::Registry(
1963                "exact launcher fixture path must be a regular file".to_owned(),
1964            ));
1965        }
1966        let mut spec = builtin_registry()
1967            .get(&agent_id)
1968            .ok_or_else(|| {
1969                NodeServerError::Registry(
1970                    "exact launcher fixture provider is unavailable".to_owned(),
1971                )
1972            })?
1973            .clone();
1974        spec.launch.program = launcher;
1975        Self::new_fixture_with_spec(config, spec)
1976    }
1977
1978    /// Shared H3B validation and spec construction for
1979    /// `new_harness_mcp_proxy_fixture` and
1980    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1981    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1982    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1983    /// the named builtin catalog entry -- detection command and expected
1984    /// process matcher rebased on the launcher's own basename, exactly the
1985    /// same shape `new_harness_mcp_proxy_fixture` always built.
1986    #[cfg(feature = "fixture")]
1987    fn h3b_provider_spec_with_program_and_args(
1988        agent_id: AgentId,
1989        provider_program: PathBuf,
1990        fixed_args: Vec<OsString>,
1991    ) -> Result<AgentSpec, NodeServerError> {
1992        if !provider_program.is_absolute() || fixed_args.len() > 32 {
1993            return Err(NodeServerError::Registry(
1994                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1995            ));
1996        }
1997        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1998            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1999        })?;
2000        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2001            return Err(NodeServerError::Registry(
2002                "H3B fixture provider must be an exact regular file".to_owned(),
2003            ));
2004        }
2005        let program = provider_program.into_os_string().into_string().map_err(|_| {
2006            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2007        })?;
2008        let fixed_args = fixed_args.into_iter().map(|argument| {
2009            argument.into_string().map_err(|_| NodeServerError::Registry(
2010                "H3B fixture argument is not Unicode".to_owned(),
2011            ))
2012        }).collect::<Result<Vec<_>, _>>()?;
2013        let argument_bytes = fixed_args.iter()
2014            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2015        if program.contains('\0')
2016            || fixed_args.iter().any(|argument| argument.contains('\0'))
2017            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2018        {
2019            return Err(NodeServerError::Registry(
2020                "H3B fixture provider or arguments are invalid".to_owned(),
2021            ));
2022        }
2023        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2024            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2025        })?.clone();
2026        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2027            .ok_or_else(|| NodeServerError::Registry(
2028                "H3B fixture provider basename is unavailable".to_owned(),
2029            ))?.to_owned();
2030        spec.detection.command = process_name.clone();
2031        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2032            name: process_name,
2033        }];
2034        spec.launch.program = program;
2035        spec.launch.fixed_args = fixed_args;
2036        Ok(spec)
2037    }
2038
2039    #[cfg(feature = "fixture")]
2040    pub fn new_harness_mcp_proxy_fixture(
2041        config: NodeServerConfig,
2042        provider_program: PathBuf,
2043        fixed_args: Vec<OsString>,
2044        harness_mcp_program: PathBuf,
2045    ) -> Result<Self, NodeServerError> {
2046        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2047        let agent_id = AgentId::new("codex")
2048            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2049        let mut spec =
2050            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2051        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2052        config.fixture_raw_pty_runtime = true;
2053        Self::new_fixture_with_spec(config, spec)
2054    }
2055
2056    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2057    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2058    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2059    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2060    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2061    /// fixture_raw_pty_runtime = true`, which tears out the live
2062    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2063    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2064    /// hardcode `acp_transport = false` for every provider, refusing
2065    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2066    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2067    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2068    /// is built from the (cloned, launcher-replaced) catalog spec, and
2069    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2070    /// unmodified catalog fact: every builtin provider this accepts
2071    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2072    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2073    /// invented here -- this constructor only removes the fixture-only
2074    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2075    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2076    /// for every ACP session, fixture or not) is what actually grants the
2077    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2078    /// this constructor never calls it directly, and does not need to.
2079    #[cfg(feature = "fixture")]
2080    pub fn new_harness_mcp_acp_launcher_fixture(
2081        config: NodeServerConfig,
2082        helper_program: PathBuf,
2083        provider: AgentId,
2084        program: PathBuf,
2085        args: Vec<OsString>,
2086    ) -> Result<Self, NodeServerError> {
2087        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2088            return Err(NodeServerError::Registry(
2089                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2090            ));
2091        }
2092        let config = config.with_harness_mcp_helper(helper_program)?;
2093        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2094        let launch = spec.launch.clone();
2095        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2096            NodeServerError::Registry(
2097                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2098                    .to_owned(),
2099            )
2100        })?;
2101        // Routes the ACP spawn itself through the exact replaced launcher
2102        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2103        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2104        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2105        // without this the ACP transport ignores `spec.launch` entirely and
2106        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2107        // codex-acp@1.10.0`), which this fixture must never reach.
2108        acp.launch_override = Some(launch);
2109        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2110        Self::new_fixture_with_spec(config, spec)
2111    }
2112
2113    #[cfg(feature = "fixture")]
2114    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2115        let mut spec = gate4agent_testkit::interactive_agent_spec();
2116        let claude_id = AgentId::new("claude")
2117            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2118        let claude = builtin_registry()
2119            .get(&claude_id)
2120            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2121        spec.id = claude_id;
2122        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2123        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2124            name: "claude".to_owned(),
2125        }];
2126        spec.capabilities.transports.pty_adapter = claude
2127            .capabilities
2128            .transports
2129            .pty_adapter
2130            .clone();
2131        spec.capabilities.adapters.resume = claude
2132            .capabilities
2133            .adapters
2134            .resume
2135            .clone();
2136        let script = spec
2137            .launch
2138            .fixed_args
2139            .pop()
2140            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2141        spec.launch.fixed_args.push(format!(
2142            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2143        ));
2144        Self::new_fixture_with_spec(config, spec)
2145    }
2146
2147    #[cfg(feature = "fixture")]
2148    pub fn new_cmd_cwd_fixture(
2149        config: NodeServerConfig,
2150        command: String,
2151    ) -> Result<Self, NodeServerError> {
2152        let mut spec = gate4agent_testkit::interactive_agent_spec();
2153        spec.id = AgentId::new("claude")
2154            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2155        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2156        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2157        spec.launch.program = command;
2158        spec.launch.fixed_args.clear();
2159        Self::new_fixture_with_spec(config, spec)
2160    }
2161
2162    #[cfg(feature = "fixture")]
2163    fn new_fixture_with_spec(
2164        config: NodeServerConfig,
2165        spec: gate4agent_types::AgentSpec,
2166    ) -> Result<Self, NodeServerError> {
2167        let catalog = AgentRegistry::new([spec])
2168            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2169        Self::new_with_registry(config, catalog)
2170    }
2171
2172    fn new_with_registry(
2173        config: NodeServerConfig,
2174        catalog: AgentRegistry,
2175    ) -> Result<Self, NodeServerError> {
2176        for profile in config.spawn_profiles.iter() {
2177            if catalog.get(&profile.provider).is_none() {
2178                return Err(NodeServerError::Registry(
2179                    "spawn profile references an unavailable provider".to_owned(),
2180                ));
2181            }
2182        }
2183        let input_settle_timeout_ms = catalog
2184            .iter()
2185            .map(|spec| spec.readiness.timeout_ms)
2186            .max()
2187            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2188            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2189            .max(MUTATION_SETTLE_TIMEOUT_MS);
2190        let (provider_contracts, provider_adapter_contracts) =
2191            provider_contract_manifest(&catalog)?;
2192        let compatibility_support = node_compatibility_support_for_manifest(
2193            &provider_contracts,
2194            &provider_adapter_contracts,
2195        )?;
2196        validate_node_negotiated_handshake_capacity(&compatibility_support)
2197            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2198        let enabled_providers = provider_contracts
2199            .iter()
2200            .map(|contract| contract.provider.clone())
2201            .collect();
2202        #[cfg(feature = "fixture")]
2203        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2204        #[cfg(not(feature = "fixture"))]
2205        let fixture_raw_pty_runtime = false;
2206        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2207            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2208                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2209            })).expect("fixture provider catalog remains bounded");
2210            (None, statuses)
2211        } else {
2212            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2213            let statuses = monitor.collect();
2214            (Some(monitor), statuses)
2215        };
2216        let state_path_lock =
2217            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2218                .map_err(|error| {
2219                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2220                })?;
2221        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2222            Some(state_path) => {
2223                let delivery_root = delivery_store_root_for_state_path(state_path)
2224                    .ok_or_else(|| {
2225                        NodeServerError::DeliveryStore(
2226                            "delivery store root could not be derived from state path".to_owned(),
2227                        )
2228                    })?;
2229                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2230                    let detail = match &error {
2231                        DeliveryStoreError::Storage(io_error) => {
2232                            format!("{error}: {io_error}")
2233                        }
2234                        _ => error.to_string(),
2235                    };
2236                    NodeServerError::DeliveryStore(detail)
2237                })?;
2238                (Some(store), bundles)
2239            }
2240            None => (None, Vec::new()),
2241        };
2242        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2243            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2244        })?;
2245        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2246            Some(state_path) => {
2247                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2248                    .ok_or(NodeServerError::ContextPackStore)?;
2249                let (store, packs) = ContextPackStore::open(context_pack_root)
2250                    .map_err(|_| NodeServerError::ContextPackStore)?;
2251                (Some(store), packs)
2252            }
2253            None => (None, Vec::new()),
2254        };
2255        let mut durable_context_catalog = ContextPackCatalog::default();
2256        for pack in durable_context_packs {
2257            durable_context_catalog
2258                .insert(pack)
2259                .map_err(|_| NodeServerError::ContextPackStore)?;
2260        }
2261        let session_environment_materializer = config
2262            .session_environment
2263            .as_ref()
2264            .map(|environment| {
2265                SessionEnvironmentMaterializer::new(
2266                    environment.root.clone(),
2267                    Arc::clone(&environment.resolver),
2268                )
2269                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2270            })
2271            .transpose()?;
2272        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2273        // (the provider version probe above already used the original catalog).
2274        #[cfg(unix)]
2275        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2276        let native_session_catalog = config.history.clone().map(|history| {
2277            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2278        });
2279        let (handle, runtime) = match config.history.clone() {
2280            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2281            None => NativeRuntime::new(catalog, config.runtime),
2282        };
2283        let native_launch_profile_control = runtime.native_launch_profile_control();
2284        let harness_mcp_registry = config
2285            .harness_mcp_helper
2286            .clone()
2287            .map(HarnessMcpProxyRegistry::new);
2288        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2289        let incarnation_id = random_incarnation_id()
2290            .map_err(NodeServerError::IncarnationIdentity)?;
2291        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2292            .map_err(durable_state_load_error)?;
2293        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2294        let managed_worktree_tombstones =
2295            std::mem::take(&mut loaded.managed_worktree_tombstones);
2296        let materializations = std::mem::take(&mut loaded.materializations);
2297        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2298        let (workspaces, records, persistence_warning) =
2299            merge_durable_state(&config.workspaces, loaded)?;
2300        let mut shared = NodeShared::new_with_incarnation(
2301            handle,
2302            config.access_token.clone(),
2303            config.node_id.clone(),
2304            incarnation_id,
2305            workspaces,
2306            enabled_providers,
2307            provider_runtime_statuses,
2308            provider_runtime_monitor,
2309            provider_contracts,
2310            provider_adapter_contracts,
2311            config.spawn_profiles.clone(),
2312            Some(native_launch_profile_control),
2313            native_session_catalog,
2314            session_environment_materializer,
2315            config.state_path.clone(),
2316            records,
2317            managed_worktrees,
2318            managed_worktree_tombstones,
2319            materializations,
2320            persistence_warning,
2321            input_settle_timeout_ms,
2322        );
2323        shared.harness_mcp_registry = harness_mcp_registry.clone();
2324        shared.session_record_retention = config.session_record_retention;
2325        shared.network_allowlist_catalog =
2326            RwLock::new(config.network_allowlist_catalog.clone());
2327        shared.bundle_catalog = RwLock::new(delivered_catalog);
2328        shared.delivery_store = Mutex::new(delivery_store);
2329        shared.context_catalog = RwLock::new(durable_context_catalog);
2330        shared.context_pack_store = Mutex::new(context_pack_store);
2331        shared.managed_spawn_replays = Mutex::new(
2332            managed_spawn_replays
2333                .into_iter()
2334                .map(|record| (record.idempotency_key.clone(), record))
2335                .collect(),
2336        );
2337        let shared = Arc::new(shared);
2338        if let Some(registry) = harness_mcp_registry {
2339            let weak = Arc::downgrade(&shared);
2340            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2341                Some(shared) => shared.publish_transient(event),
2342                None => false,
2343            }));
2344        }
2345        if shared.persistence_error().is_none() {
2346            shared.persist_state().map_err(|error| {
2347                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2348            })?;
2349        }
2350        Ok(Self {
2351            config,
2352            runtime,
2353            shared,
2354            events,
2355            state_path_lock,
2356        })
2357    }
2358
2359    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2360        NodeShutdownHandle {
2361            shared: Arc::clone(&self.shared),
2362        }
2363    }
2364
2365    #[cfg(feature = "fixture")]
2366    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2367        &self,
2368    ) -> SpawnManagedWorktreeV2FailureProbe {
2369        self.shared
2370            .fixture_spawn_managed_worktree_v2_failure_probe
2371            .clone()
2372    }
2373
2374    /// Installs one immutable host-local environment profile before the server runs.
2375    ///
2376    /// Native resolvers remain inside the runtime; the shared node registry keeps
2377    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2378    pub fn install_environment_profile(
2379        &mut self,
2380        profile: NodeEnvironmentProfile,
2381    ) -> Result<(), NodeServerError> {
2382        if !self.shared.enabled_providers.contains(profile.provider()) {
2383            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2384                profile.provider().clone(),
2385            ));
2386        }
2387        let (binding, native_profiles, materialization) = profile.into_parts();
2388        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2389            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2390        }
2391        let mut profiles = self
2392            .shared
2393            .environment_profiles
2394            .write()
2395            .unwrap_or_else(|poisoned| poisoned.into_inner());
2396        if profiles.contains_key(&binding.id) {
2397            return Err(NodeServerError::DuplicateEnvironmentProfile(
2398                binding.id.clone(),
2399            ));
2400        }
2401        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2402            return Err(NodeServerError::EnvironmentProfileCapacity {
2403                max: MAX_NODE_ENVIRONMENT_PROFILES,
2404            });
2405        }
2406        for native_id in binding.native_profile_ids() {
2407            if profiles.values().any(|current| {
2408                current.native_profile_ids().any(|current_id| current_id == native_id)
2409            }) {
2410                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2411                    native_id.to_string(),
2412                ));
2413            }
2414        }
2415
2416        let mut installed = Vec::with_capacity(native_profiles.len());
2417        for native_profile in native_profiles {
2418            let native_id = native_profile.id().clone();
2419            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2420                for installed_id in installed.into_iter().rev() {
2421                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2422                }
2423                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2424            }
2425            installed.push(native_id);
2426        }
2427        if let Some(materialization) = materialization {
2428            self.shared
2429                .environment_materialization_profiles
2430                .write()
2431                .unwrap_or_else(|poisoned| poisoned.into_inner())
2432                .insert(binding.id.clone(), materialization);
2433        }
2434        profiles.insert(binding.id.clone(), binding);
2435        Ok(())
2436    }
2437
2438    /// Installs one immutable host-local bundle before the server runs.
2439    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2440        if self.shared.session_environment_materializer.is_none() {
2441            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2442        }
2443        let mut catalog = self
2444            .shared
2445            .bundle_catalog
2446            .write()
2447            .unwrap_or_else(|poisoned| poisoned.into_inner());
2448        catalog
2449            .insert_idempotent(bundle)
2450            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2451        Ok(())
2452    }
2453
2454    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2455        let shutdown = self.shutdown_handle();
2456        let run = self.run();
2457        tokio::pin!(run);
2458        tokio::select! {
2459            result = &mut run => result,
2460            signal = wait_for_ctrl_signal() => {
2461                signal?;
2462                let shutdown_result = shutdown.request_shutdown().await;
2463                let run_result = run.await;
2464                shutdown_result.and(run_result)
2465            }
2466        }
2467    }
2468
2469    pub async fn run(self) -> Result<(), NodeServerError> {
2470        let NodeServer {
2471            config,
2472            mut runtime,
2473            shared,
2474            events,
2475            state_path_lock: _state_path_lock,
2476        } = self;
2477        shared.reconcile_materializations();
2478        shared.reconcile_managed_worktrees().await;
2479        shared.retire_unavailable_session_records().await;
2480        shared.reconcile_context_pack_exports().await;
2481        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2482        // observes sessions through ACP where a provider has it; there is no
2483        // hook ingress.
2484        let endpoint = config.endpoint.clone();
2485        let call_home = config.call_home;
2486        let call_home_identity = config.call_home_identity.clone();
2487        let api_listen = config.api_listen;
2488        let bridge_listen = config.bridge_listen;
2489        let bridge_underlay = config.bridge_underlay;
2490        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2491            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2492        let accept_shared = Arc::clone(&shared);
2493        let api_shared = Arc::clone(&shared);
2494        let bridge_shared = Arc::clone(&shared);
2495        let shutdown_shared = Arc::clone(&shared);
2496        let shutdown_timeout = Duration::from_millis(
2497            config.runtime.provider_shutdown_timeout_ms.max(1),
2498        );
2499        let result = {
2500            let runtime_loop = drive_runtime_until_shutdown(
2501                &mut runtime,
2502                events,
2503                Arc::clone(&shared),
2504                shutdown_timeout,
2505            );
2506            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2507            let api_loop = http_api::run(api_listen, api_shared);
2508            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2509            tokio::pin!(runtime_loop);
2510            tokio::pin!(accept_loop);
2511            tokio::pin!(api_loop);
2512            tokio::pin!(bridge_loop);
2513            tokio::select! {
2514                runtime_result = &mut runtime_loop => {
2515                    let shutdown_result = shutdown_shared
2516                        .begin_shutdown()
2517                        .await
2518                        .map_err(NodeServerError::ShutdownDispatch);
2519                    let (accept_result, api_result, bridge_result) =
2520                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2521                    match runtime_result {
2522                        Err(error) => Err(error),
2523                        Ok(()) => shutdown_result
2524                            .and(accept_result)
2525                            .and(api_result.map_err(NodeServerError::HttpApi))
2526                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2527                    }
2528                }
2529                accept_result = &mut accept_loop => {
2530                    let shutdown_result = shutdown_shared
2531                        .begin_shutdown()
2532                        .await
2533                        .map_err(NodeServerError::ShutdownDispatch);
2534                    let (runtime_result, api_result, bridge_result) =
2535                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2536                    match accept_result {
2537                        Err(error) => Err(error),
2538                        Ok(()) => shutdown_result
2539                            .and(runtime_result)
2540                            .and(api_result.map_err(NodeServerError::HttpApi))
2541                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2542                    }
2543                }
2544                api_result = &mut api_loop => {
2545                    let shutdown_result = shutdown_shared
2546                        .begin_shutdown()
2547                        .await
2548                        .map_err(NodeServerError::ShutdownDispatch);
2549                    let (runtime_result, accept_result, bridge_result) =
2550                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2551                    match api_result {
2552                        Err(error) => Err(NodeServerError::HttpApi(error)),
2553                        Ok(()) => shutdown_result
2554                            .and(runtime_result)
2555                            .and(accept_result)
2556                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2557                    }
2558                }
2559                bridge_result = &mut bridge_loop => {
2560                    let shutdown_result = shutdown_shared
2561                        .begin_shutdown()
2562                        .await
2563                        .map_err(NodeServerError::ShutdownDispatch);
2564                    let (runtime_result, accept_result, api_result) =
2565                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2566                    match bridge_result {
2567                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2568                        Ok(()) => shutdown_result
2569                            .and(runtime_result)
2570                            .and(accept_result)
2571                            .and(api_result.map_err(NodeServerError::HttpApi)),
2572                    }
2573                }
2574            }
2575        };
2576        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2577        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2578        // NodeShutdownHandle also clones that Arc, so without an explicit release
2579        // here a supervisor that keeps the shutdown handle cannot reopen the same
2580        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2581        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2582        if let Ok(mut guard) = shared.delivery_store.lock() {
2583            *guard = None;
2584        }
2585        if let Ok(mut guard) = shared.context_pack_store.lock() {
2586            *guard = None;
2587        }
2588        result
2589    }
2590}
2591
2592async fn drive_runtime_until_shutdown(
2593    runtime: &mut NativeRuntime,
2594    events: EventSubscription,
2595    shared: Arc<NodeShared>,
2596    shutdown_timeout: Duration,
2597) -> Result<(), NodeServerError> {
2598    // Published once, never per iteration. The worker loops inside
2599    // `NativeRuntime` fold into this profile continuously; turning it into
2600    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2601    let _ = shared
2602        .shell_efficiency_profile
2603        .set(runtime.shell_efficiency_profile());
2604    let mut shutdown_started = None;
2605    let mut last_retention_sweep = Instant::now();
2606    loop {
2607        // Every phase below is timed unconditionally -- see
2608        // `drive_loop_profile`'s own doc comment for why an always-on,
2609        // allocation-free ring is cheap enough to never need a feature
2610        // flag. This is the only place these numbers can be taken: the
2611        // phases are calls made directly from this loop, not something a
2612        // callee could time on our behalf.
2613        let runtime_tick_start = Instant::now();
2614        let tick_result = runtime.tick().await;
2615        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2616
2617        let event_drain_start = Instant::now();
2618        // Every command the kernel refused, named. `NativeRuntimeTick`
2619        // has carried these outcomes all along and nothing read them, so a
2620        // rejected command -- a hook event denied by runtime policy, a
2621        // stale generation, an unsupported capability -- vanished at the
2622        // last step of the loop that produced it. A refusal the operator
2623        // cannot see is indistinguishable from an event that never arrived,
2624        // and telling those two apart is most of debugging this stack.
2625        for outcome in &tick_result.command_outcomes {
2626            if let Err(error) = &outcome.result {
2627                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2628                tracing::warn!(
2629                    command_id = outcome.command_id.0,
2630                    cause = %error,
2631                    "control command rejected"
2632                );
2633                shared.record_rejected_command(
2634                    outcome.command_id,
2635                    error.to_string(),
2636                    error.is_unsupported_transport(),
2637                );
2638            }
2639        }
2640        let mut events_drained: u64 = 0;
2641        while let Ok(event) = events.try_recv() {
2642            events_drained += 1;
2643            let clean_exit = matches!(
2644                event.event,
2645                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2646            );
2647            let instance_id = event.instance_id;
2648            let generation = event.generation;
2649            // Resolve and spawn before publish_control: publish_control's own
2650            // managed-record bookkeeping (reconcile_managed_record) downgrades
2651            // this exact record from Live to Dormant and clears
2652            // active_session for every Exited event, inside the same
2653            // synchronous call. Capturing (record_id, session) here — before
2654            // that runs — is the only way to observe them still Live and
2655            // still populated; the export itself must still run on its own
2656            // detached task (never awaited inline on this loop) because it
2657            // needs NativeRuntime::tick() to keep running to ever settle, and
2658            // tick() is only ever driven by this same loop. See
2659            // reconcile_context_pack_export_for_record's doc comment.
2660            if clean_exit {
2661                if let Some((record_id, session)) =
2662                    shared.managed_record_export_target_for_instance(instance_id, generation)
2663                {
2664                    let export_shared = Arc::clone(&shared);
2665                    tokio::spawn(async move {
2666                        export_shared
2667                            .reconcile_context_pack_export_for_record(&record_id, session)
2668                            .await;
2669                    });
2670                }
2671            }
2672            shared.publish_control(event);
2673        }
2674        let event_drain_elapsed = event_drain_start.elapsed();
2675
2676        let publish_terminal_frames_start = Instant::now();
2677        shared.publish_terminal_frames();
2678        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2679
2680        // Physical PTY count and the runtime's own per-phase tick timings
2681        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2682        // only `NodeShared`) cannot reach directly -- this loop is the one
2683        // place holding `&mut NativeRuntime`, so it is the only place that
2684        // can copy either out.
2685        shared
2686            .native_session_gauge
2687            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2688        *shared
2689            .runtime_tick_profile
2690            .lock()
2691            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2692
2693        let remainder_start = Instant::now();
2694        if shared.shutdown.load(Ordering::Acquire) {
2695            let started = *shutdown_started.get_or_insert_with(Instant::now);
2696            let snapshot = shared.handle.snapshot();
2697            let sessions_settled = snapshot.sessions.iter().all(|session| {
2698                !matches!(
2699                    session.status,
2700                    gate4agent_types::SessionStatus::Starting
2701                        | gate4agent_types::SessionStatus::Running
2702                        | gate4agent_types::SessionStatus::Stopping
2703                )
2704            });
2705            if runtime.active_native_sessions() == 0 && sessions_settled {
2706                return Ok(());
2707            }
2708            if started.elapsed() >= shutdown_timeout {
2709                return Err(NodeServerError::ShutdownTimedOut {
2710                    active_native_sessions: runtime.active_native_sessions(),
2711                });
2712            }
2713        }
2714        let remainder_elapsed = remainder_start.elapsed();
2715
2716        {
2717            let mut profile = shared
2718                .drive_loop_profile
2719                .lock()
2720                .unwrap_or_else(|poisoned| poisoned.into_inner());
2721            profile.record_runtime_tick(runtime_tick_elapsed);
2722            profile.record_event_drain(event_drain_elapsed);
2723            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2724            profile.record_remainder(remainder_elapsed);
2725            profile.note_iteration(Instant::now());
2726        }
2727
2728        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2729        // applied zero observations (`tick_result.observations_applied`,
2730        // which already folds in any terminal frames collected -- see
2731        // `NativeEffectDispatcher::drain_observations`) AND the
2732        // control-event subscription drained above forwarded zero events.
2733        // Command ingress and dispatched effects are deliberately NOT part
2734        // of this definition: an idle node still runs `step_control_plane`
2735        // and every provider supervisor's `tick` regardless, every 10ms --
2736        // so "nothing" here means nothing OBSERVABLE left this iteration,
2737        // matching what a reader would assume from `drive_loop_iterations`'s
2738        // own field name, not that the tick itself was free of work.
2739        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2740        if tick_result.observations_applied == 0 && events_drained == 0 {
2741            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2742        }
2743
2744        // A sweep cadence, not a policy value -- see
2745        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2746        // policy itself (whether this does anything at all) lives entirely
2747        // in `NodeShared::session_record_retention`.
2748        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2749            shared.retire_unavailable_session_records().await;
2750            last_retention_sweep = Instant::now();
2751        }
2752
2753        sleep(Duration::from_millis(10)).await;
2754    }
2755}
2756
2757#[derive(Clone)]
2758pub struct NodeShutdownHandle {
2759    shared: Arc<NodeShared>,
2760}
2761
2762impl NodeShutdownHandle {
2763    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2764        self.shared
2765            .begin_shutdown()
2766            .await
2767            .map_err(NodeServerError::ShutdownDispatch)
2768    }
2769}
2770
2771async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2772    #[cfg(windows)]
2773    {
2774    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2775    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2776    return tokio::select! {
2777        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2778        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2779    };
2780    }
2781    #[cfg(unix)]
2782    {
2783        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2784        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2785        tokio::select! {
2786            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2787            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2788        }
2789    }
2790}
2791
2792/// The providers this node actually runs.
2793///
2794/// `builtin_registry()` is a reference catalog of everything the project has
2795/// ever described; the fleet is this list, and it is deliberately short. A
2796/// provider only belongs here if it can be exercised for real -- there is a
2797/// subscription behind it and a failure in it can be reproduced and
2798/// debugged. Carrying one that cannot is carrying code nobody can verify,
2799/// which is worse than not supporting it: it looks supported.
2800///
2801/// A provider dropped from the fleet by this rule is removed outright, not
2802/// carried dark: no catalog entry, no adapter, no dead code claiming support
2803/// for something nobody can verify runs.
2804fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2805    let specs = builtin_registry()
2806        .iter()
2807        .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2808        .cloned()
2809        .collect::<Vec<_>>();
2810    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2811}
2812
2813fn provider_contract_manifest(
2814    catalog: &AgentRegistry,
2815) -> Result<
2816    (
2817        Vec<ProviderContractSupport>,
2818        Vec<ProviderAdapterContractSupport>,
2819    ),
2820    NodeServerError,
2821> {
2822    let mut provider_contracts = Vec::new();
2823    let mut provider_adapter_contracts = Vec::new();
2824    for spec in catalog.iter() {
2825        let provider = spec.id.clone();
2826        let revision = ProviderContractRevision::new(spec.revision.clone())
2827            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2828        provider_contracts.push(ProviderContractSupport {
2829            provider: provider.clone(),
2830            revision,
2831        });
2832        for (family, binding) in declared_adapter_bindings(spec)? {
2833            let revision = AdapterContractRevision::new(binding.revision.clone())
2834                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2835            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2836                provider: provider.clone(),
2837                family,
2838                adapter_id: binding.id.clone(),
2839                revision,
2840            });
2841        }
2842    }
2843    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2844        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2845    Ok((provider_contracts, provider_adapter_contracts))
2846}
2847
2848fn declared_adapter_bindings(
2849    spec: &AgentSpec,
2850) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2851    let mut bindings = Vec::new();
2852    push_declared_binding(
2853        &mut bindings,
2854        AdapterFamily::PtySemantic,
2855        spec.capabilities.transports.pty_adapter.as_ref(),
2856    )?;
2857    push_declared_binding(
2858        &mut bindings,
2859        AdapterFamily::Pipe,
2860        spec.capabilities.adapters.pty_sidecar.as_ref(),
2861    )?;
2862    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2863    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2864        gate4agent_types::PipeProtocol::SemanticNdjson
2865        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2866        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2867    });
2868    push_declared_binding(
2869        &mut bindings,
2870        AdapterFamily::Pipe,
2871        pipe_transport
2872            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2873            .map(|transport| &transport.adapter),
2874    )?;
2875    push_declared_binding(
2876        &mut bindings,
2877        AdapterFamily::Acp,
2878        spec.capabilities
2879            .transports
2880            .acp
2881            .as_ref()
2882            .map(|transport| &transport.adapter),
2883    )?;
2884    push_declared_binding(
2885        &mut bindings,
2886        AdapterFamily::Hook,
2887        spec.capabilities.adapters.hook.as_ref(),
2888    )?;
2889    push_declared_binding(
2890        &mut bindings,
2891        AdapterFamily::ManagedHook,
2892        spec.capabilities.adapters.managed_hook.as_ref(),
2893    )?;
2894    push_declared_binding(
2895        &mut bindings,
2896        AdapterFamily::OneShot,
2897        pipe_transport
2898            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2899            .map(|transport| &transport.adapter),
2900    )?;
2901    push_declared_binding(
2902        &mut bindings,
2903        AdapterFamily::OneShot,
2904        spec.capabilities.adapters.one_shot.as_ref(),
2905    )?;
2906    push_declared_binding(
2907        &mut bindings,
2908        AdapterFamily::History,
2909        spec.capabilities.adapters.history.as_ref(),
2910    )?;
2911    push_declared_binding(
2912        &mut bindings,
2913        AdapterFamily::Resume,
2914        spec.capabilities.adapters.resume.as_ref(),
2915    )?;
2916    push_declared_binding(
2917        &mut bindings,
2918        AdapterFamily::SessionOptions,
2919        spec.capabilities.adapters.session_options.as_ref(),
2920    )?;
2921    push_declared_binding(
2922        &mut bindings,
2923        AdapterFamily::CapabilityProbe,
2924        spec.capabilities.adapters.capability_probe.as_ref(),
2925    )?;
2926    Ok(bindings)
2927}
2928
2929fn push_declared_binding<'a>(
2930    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2931    family: AdapterFamily,
2932    binding: Option<&'a AdapterBinding>,
2933) -> Result<(), NodeServerError> {
2934    let Some(binding) = binding else {
2935        return Ok(());
2936    };
2937    if let Some((_, existing)) = bindings
2938        .iter()
2939        .find(|(existing_family, _)| *existing_family == family)
2940    {
2941        if *existing == binding {
2942            return Ok(());
2943        }
2944        return Err(NodeServerError::ProviderContractManifest(format!(
2945            "provider declares conflicting {family:?} adapter bindings"
2946        )));
2947    }
2948    bindings.push((family, binding));
2949    Ok(())
2950}
2951
2952fn merge_durable_state(
2953    configured: &[WorkspaceConfig],
2954    mut loaded: LoadedNodeState,
2955) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2956    let mut workspaces = configured
2957        .iter()
2958        .cloned()
2959        .map(|workspace| (workspace.workspace_id.clone(), workspace))
2960        .collect::<BTreeMap<_, _>>();
2961    let mut roots = workspaces
2962        .values()
2963        .map(|workspace| {
2964            (
2965                platform::root_identity(&workspace.canonical_root),
2966                workspace.workspace_id.clone(),
2967            )
2968        })
2969        .collect::<BTreeMap<_, _>>();
2970    let mut warning = loaded
2971        .warning
2972        .take()
2973        .map(|message| session_registry::sanitized_persistence_summary(&message));
2974
2975    for (workspace_id, canonical_root) in loaded.workspaces {
2976        if let Some(existing) = workspaces.get(&workspace_id) {
2977            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2978                return Err(NodeServerError::DurableState(io::Error::new(
2979                    io::ErrorKind::InvalidData,
2980                    DURABLE_STATE_CONFLICT_ERROR,
2981                )));
2982            }
2983            continue;
2984        }
2985        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2986            return Err(NodeServerError::DurableState(io::Error::new(
2987                io::ErrorKind::InvalidData,
2988                DURABLE_STATE_CONFLICT_ERROR,
2989            )));
2990        }
2991        if !Path::new(&canonical_root).is_dir() {
2992            warning.get_or_insert_with(|| {
2993                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2994            });
2995            continue;
2996        }
2997        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2998            NodeServerError::DurableState(io::Error::new(
2999                io::ErrorKind::InvalidData,
3000                DURABLE_STATE_CONFLICT_ERROR,
3001            ))
3002        })?;
3003        roots.insert(
3004            platform::root_identity(&workspace.canonical_root),
3005            workspace.workspace_id.clone(),
3006        );
3007        workspaces.insert(workspace_id, workspace);
3008    }
3009
3010    for record in &mut loaded.records {
3011        match workspaces.get(&record.workspace_id) {
3012            Some(workspace)
3013                if platform::roots_equal(
3014                    &workspace.canonical_root,
3015                    windows_path_text(&record.canonical_root),
3016                ) => {}
3017            Some(_) => {
3018                return Err(NodeServerError::DurableState(io::Error::new(
3019                    io::ErrorKind::InvalidData,
3020                    DURABLE_STATE_CONFLICT_ERROR,
3021                )));
3022            }
3023            None => {
3024                record.state = ManagedSessionState::Unavailable;
3025                record.active_session = None;
3026                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3027            }
3028        }
3029    }
3030    Ok((workspaces.into_values().collect(), loaded.records, warning))
3031}
3032
3033/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3034/// own doc for the mechanism this exists to close.
3035struct RejectedCommandReason {
3036    message: String,
3037    /// True when the kernel refused this command specifically because the
3038    /// agent does not declare the requested transport (`KernelCommandError::
3039    /// UnsupportedTransport`) -- lets the reader choose the named
3040    /// `NodeFailureCode::UnsupportedTransport` over the generic
3041    /// `BackendOperationFailed` bucket, so this one well-known rejection
3042    /// keeps its identity all the way to the operator wire.
3043    unsupported_transport: bool,
3044    expires_at: Instant,
3045}
3046
3047struct NodeShared {
3048    handle: Gate4AgentHandle,
3049    access_token: String,
3050    node_id: NodeId,
3051    incarnation_id: NodeIncarnationId,
3052    started_at_unix_ms: u64,
3053    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3054    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3055    managed_worktree_profiles:
3056        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3057    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3058    enabled_providers: Vec<AgentId>,
3059    provider_runtime_statuses: ProviderRuntimeStatuses,
3060    provider_runtime_status_updates:
3061        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3062    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3063    provider_contracts: Vec<ProviderContractSupport>,
3064    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3065    spawn_profiles: SpawnProfileRegistry,
3066    environment_profiles:
3067        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3068    environment_materialization_profiles:
3069        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3070    /// Station **network** allowlist catalog (opaque policy ids only).
3071    /// Empty by default — any `network_allowlist` on spawn must be registered
3072    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3073    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3074    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3075    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3076    /// Never holds cookies / OAuth / proxy credentials.
3077    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3078    /// Node-local exclusive dig2browser station profile leases (feature
3079    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3080    /// cleared on session end. Never ImportSession / cookies on C2.
3081    #[cfg(feature = "dig2-station-probe")]
3082    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3083    bundle_catalog: RwLock<BundleCatalog>,
3084    delivery_store: Mutex<Option<DeliveryStore>>,
3085    context_pack_store: Mutex<Option<ContextPackStore>>,
3086    context_catalog: RwLock<ContextPackCatalog>,
3087    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3088    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3089    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3090    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3091    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3092    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3093    /// nick -> the session that holds that m4a account (for letter wakes).
3094    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3095    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3096    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3097    managed_spawn_replays:
3098        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3099    controller: Mutex<Option<ControllerLease>>,
3100    history: Mutex<NodeEventHistory>,
3101    event_tx: broadcast::Sender<NodeEventEnvelope>,
3102    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3103    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3104    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3105    /// exactly rather than riding the durable `event_tx` history (see
3106    /// `publish`'s own assertion): a subscriber who never negotiated
3107    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3108    /// these, the same guarantee `include_terminal_frame_events` already
3109    /// gives terminal frames.
3110    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3111    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3112    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3113    /// broadcast rather than a ride on the durable `event_tx` history. A
3114    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3115    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3116    /// finite capacity and its willingness to silently evict a lagging
3117    /// subscriber's backlog is exactly how the call vanished with neither
3118    /// side ever logging it (see `publish_transient`'s own doc).
3119    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3120    terminal_frame_watermarks:
3121        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3122    next_connection_id: AtomicU64,
3123    next_instance_id: AtomicU64,
3124    next_command_id: AtomicU64,
3125    shutdown: AtomicBool,
3126    shutdown_notify: Notify,
3127    preauth_slots: Arc<Semaphore>,
3128    authenticated_slots: Arc<Semaphore>,
3129    inspection_slots: Arc<Semaphore>,
3130    mutation_gate: AsyncMutex<()>,
3131    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3132    session_records: Mutex<SessionRecords>,
3133    state_path: Option<PathBuf>,
3134    persistence_error: RwLock<Option<String>>,
3135    state_transaction: Mutex<()>,
3136    input_settle_timeout_ms: u64,
3137    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3138    /// comment. Written once per iteration by
3139    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3140    drive_loop_profile: Mutex<DriveLoopProfiler>,
3141    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3142    /// so an overwrite each iteration is cheap. The runtime itself is not
3143    /// reachable from here (it is owned locally by
3144    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3145    /// tick timings reach `GET /metrics`.
3146    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3147    /// The live shell-efficiency profile, shared with the per-instance
3148    /// worker loops that fold into it.
3149    ///
3150    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3151    /// profile` above keeps: producing a snapshot sorts every ring behind
3152    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3153    /// field set at construction because `NodeShared` is built before the
3154    /// `NativeRuntime` that owns the profile exists -- the drive loop
3155    /// publishes it once, before its first iteration. Unset means the
3156    /// runtime has not started driving yet, which `GET /metrics` reports as
3157    /// empty series rather than as zeroes it never measured.
3158    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3159    /// Physical PTY sessions the native runtime currently owns
3160    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3161    /// loop iteration -- deliberately a separate count from
3162    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3163    /// session bookkeeping), since the two can disagree and the gap is
3164    /// itself a diagnostic signal `GET /metrics` exposes.
3165    native_session_gauge: AtomicUsize,
3166    /// Lifetime totals for the per-connection serve loop, summed across
3167    /// every live connection.
3168    ///
3169    /// The drive loop's own phase timings account for about ten
3170    /// microseconds an iteration on an idle node, yet the process burns a
3171    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3172    /// with none, 23% with two, measured). That gap is somewhere in
3173    /// serving a connection, and there was no way to see it: iterations
3174    /// says whether the loop is spinning rather than sleeping, and events
3175    /// says whether it is spinning over real work or over nothing.
3176    connection_loop_iterations: AtomicU64,
3177    connection_events_sent: AtomicU64,
3178    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3179    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3180    /// `NativeRuntime::tick` observations AND forwarded zero control
3181    /// events; see the exact "produced nothing" definition at the site in
3182    /// `drive_runtime_until_shutdown` that increments these. Read twice
3183    /// and divide by the interval, same rule as `connections` above --
3184    /// `idle / total` is backlog item 5's "wake on work" number.
3185    /// Lifetime count of control commands the kernel refused.
3186    rejected_commands_total: AtomicU64,
3187    /// The named reason behind a recent kernel rejection, by the
3188    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3189    /// command -- populated by `drive_runtime_until_shutdown` for every
3190    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3191    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3192    /// `CommandId` among them.
3193    ///
3194    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3195    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3196    /// problem -- full or disconnected -- never for the kernel refusing the
3197    /// command's content (an unsupported transport, an unknown agent, ...);
3198    /// that verdict exists only once `NativeRuntime::tick` has processed the
3199    /// command, one drive-loop iteration later, in a different task than
3200    /// whichever caller is awaiting the spawn. Without this map, a spawn
3201    /// wait loop that polls `self.handle.snapshot()` for a session that
3202    /// will now never appear has no way to learn why -- it can only spin
3203    /// until its own commit deadline and report a generic "did not commit"
3204    /// timeout, discarding a reason the kernel already named precisely (see
3205    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3206    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3207    drive_loop_iterations_total: AtomicU64,
3208    drive_loop_iterations_idle: AtomicU64,
3209    /// The operator-chosen age/keep-N policy the retention sweep applies to
3210    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3211    /// see `SessionRecordRetentionConfig`'s own doc comment.
3212    session_record_retention: SessionRecordRetentionConfig,
3213    /// Lifetime count of managed session records the retention sweep has
3214    /// retired. Same tier as `rejected_commands_total` above: a live,
3215    /// process-lifetime instrument, never persisted and never reloaded from
3216    /// `state-v1.json` -- a restart legitimately resets it, matching every
3217    /// other `*_total` counter here.
3218    retired_records_total: AtomicU64,
3219    #[cfg(feature = "fixture")]
3220    fixture_semantic_hook_policy: bool,
3221    #[cfg(feature = "fixture")]
3222    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3223}
3224
3225impl Drop for NodeShared {
3226    fn drop(&mut self) {
3227        SessionLifecycleHook::on_shutdown(self);
3228    }
3229}
3230
3231#[derive(Clone, Debug, Eq, PartialEq)]
3232struct SessionBinding {
3233    workspace_id: WorkspaceId,
3234    generation: SessionGeneration,
3235    runtime_policy: ProviderRuntimePolicy,
3236    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3237    record_id: Option<SessionRecordId>,
3238    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3239    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3240    bundle: Option<ResolvedBundleReceipt>,
3241    context: Option<ResolvedContextPackReceipt>,
3242    materialization_id: Option<MaterializationId>,
3243}
3244
3245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3246enum SpawnRecordPolicy {
3247    ProviderIdentityOnly,
3248    Always,
3249}
3250
3251struct NativeEnvironmentSelectionGuard {
3252    control: Option<NativeLaunchProfileControl>,
3253    instance_id: AgentInstanceId,
3254}
3255
3256struct NativeInstanceOverlayGuard {
3257    control: Option<NativeLaunchProfileControl>,
3258    instance_id: AgentInstanceId,
3259}
3260
3261struct NativeHarnessMcpOverlayGuard {
3262    control: NativeLaunchProfileControl,
3263    instance_id: AgentInstanceId,
3264}
3265
3266impl NativeHarnessMcpOverlayGuard {
3267    fn retain(self) { std::mem::forget(self); }
3268}
3269
3270impl Drop for NativeHarnessMcpOverlayGuard {
3271    fn drop(&mut self) {
3272        self.control
3273            .clear_native_mcp_server_launch_overlay(self.instance_id);
3274    }
3275}
3276
3277impl NativeInstanceOverlayGuard {
3278    fn retain(mut self) {
3279        self.control = None;
3280    }
3281}
3282
3283impl Drop for NativeInstanceOverlayGuard {
3284    fn drop(&mut self) {
3285        if let Some(control) = self.control.as_ref() {
3286            control.clear_native_instance_launch_overlay(self.instance_id);
3287        }
3288    }
3289}
3290
3291enum PreparedNativeLaunchOverlay {
3292    Environment(NativeLaunchEnvironmentOverlay),
3293    Instance(NativeInstanceLaunchOverlay),
3294}
3295
3296impl NativeEnvironmentSelectionGuard {
3297    fn retain(mut self) {
3298        self.control = None;
3299    }
3300}
3301
3302impl Drop for NativeEnvironmentSelectionGuard {
3303    fn drop(&mut self) {
3304        if let Some(control) = self.control.as_ref() {
3305            control.clear_native_launch_profile_selection(self.instance_id);
3306        }
3307    }
3308}
3309
3310struct SessionMaterializationGuard<'a> {
3311    shared: &'a NodeShared,
3312    id: Option<MaterializationId>,
3313    /// Absolute ProviderHome (`home/`) under the session materialization root.
3314    /// Used for Claude station network `--settings` sidecar writes.
3315    provider_home: Option<PathBuf>,
3316}
3317
3318impl SessionMaterializationGuard<'_> {
3319    fn id(&self) -> Option<&MaterializationId> {
3320        self.id.as_ref()
3321    }
3322
3323    fn provider_home(&self) -> Option<&Path> {
3324        self.provider_home.as_deref()
3325    }
3326
3327    fn retain(mut self) {
3328        self.id = None;
3329    }
3330}
3331
3332impl Drop for SessionMaterializationGuard<'_> {
3333    fn drop(&mut self) {
3334        if let Some(id) = self.id.take() {
3335            let _ = self.shared.cleanup_materialization(&id);
3336        }
3337    }
3338}
3339
3340
3341/// Port: product-side reactions to session lifecycle transitions
3342/// (station lease, harness MCP overlay/registry, environment launch overlays).
3343trait SessionLifecycleHook {
3344    /// A binding was removed (unbind or spawn rollback).
3345    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3346    /// A session reached a terminal control event that revokes its harness MCP door.
3347    fn on_session_ended(&self, address: &SessionAddress);
3348    /// Node shutdown (or drop): revoke every harness MCP reservation.
3349    fn on_shutdown(&self);
3350    /// A harness MCP reservation was aborted before activation.
3351    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3352    /// Release session-owned materialization for a removed binding.
3353    fn cleanup_session_materialization(
3354        &self,
3355        address: &SessionAddress,
3356        binding: &SessionBinding,
3357    ) -> Result<(), NodeFailure>;
3358}
3359
3360impl SessionLifecycleHook for NodeShared {
3361    fn on_session_ended(&self, address: &SessionAddress) {
3362        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3363            registry.revoke_session(address);
3364        }
3365        if let Some(control) = self.native_launch_profile_control.as_ref() {
3366            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3367        }
3368    }
3369
3370    fn on_shutdown(&self) {
3371        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3372            let instances = registry.shutdown();
3373            if let Some(control) = self.native_launch_profile_control.as_ref() {
3374                for instance_id in instances {
3375                    control.clear_native_mcp_server_launch_overlay(instance_id);
3376                }
3377            }
3378        }
3379    }
3380
3381    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3382        if let Some(control) = self.native_launch_profile_control.as_ref() {
3383            control.clear_native_mcp_server_launch_overlay(instance_id);
3384        }
3385    }
3386
3387    fn cleanup_session_materialization(
3388        &self,
3389        address: &SessionAddress,
3390        binding: &SessionBinding,
3391    ) -> Result<(), NodeFailure> {
3392        self.cleanup_session_owned_materialization(address, binding)
3393    }
3394
3395    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3396        // Exclusive dig2browser station profile lease is node-local; clear
3397        // on every session unbind / rollback. Never ImportSession/cookies.
3398        #[cfg(feature = "dig2-station-probe")]
3399        {
3400            crate::dig2_station_lease::release_lease_for_holder(
3401                &self.browser_station_leases,
3402                address.session.instance_id,
3403            );
3404        }
3405        if let Some(control) = self.native_launch_profile_control.as_ref() {
3406            control.clear_native_mcp_server_launch_overlay(
3407                address.session.instance_id,
3408            );
3409        }
3410        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3411            registry.revoke_session(address);
3412        }
3413        if removed
3414            .is_some_and(|binding| {
3415                binding.environment_profile.is_some()
3416                    || binding.bundle.is_some()
3417                    || binding.context.is_some()
3418            })
3419        {
3420            if let Some(control) = self.native_launch_profile_control.as_ref() {
3421                control.clear_native_launch_profile_selection(
3422                    address.session.instance_id,
3423                );
3424                control.clear_native_instance_launch_overlay(
3425                    address.session.instance_id,
3426                );
3427            }
3428        }
3429    }
3430}
3431
3432struct SpawnPrepareRequest<'r> {
3433    instance_id: AgentInstanceId,
3434    address: &'r SessionAddress,
3435    provider: &'r AgentId,
3436    mode: SessionMode,
3437    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3438    bundle: Option<&'r ResolvedBundleReceipt>,
3439    context: Option<&'r ResolvedContextPackReceipt>,
3440    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3441    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3442    approval_level: ApprovalLevel,
3443    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3444    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3445}
3446
3447/// Guards held between "session identity minted" and "launch committed".
3448/// Field order is the drop order and matches the pre-refactor local order
3449/// (instance overlay, environment selection, materialization, harness, station lease).
3450struct PreparedSpawn<'a> {
3451    instance_overlay: Option<NativeInstanceOverlayGuard>,
3452    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3453    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3454    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3455    #[cfg(feature = "dig2-station-probe")]
3456    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3457    materialization_id: Option<MaterializationId>,
3458}
3459
3460impl PreparedSpawn<'_> {
3461    /// Session-owned launch state now belongs to the binding.
3462    fn retain_session_guards(&mut self) {
3463        if let Some(selection) = self.environment_selection.take() {
3464            selection.retain();
3465        }
3466        if let Some(overlay) = self.instance_overlay.take() {
3467            overlay.retain();
3468        }
3469        if let Some(guard) = self.materialization_guard.take() {
3470            guard.retain();
3471        }
3472    }
3473
3474    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3475    fn retain_launch_guards(&mut self) {
3476        if let Some(overlay) = self.harness_mcp_overlay.take() {
3477            overlay.retain();
3478        }
3479        #[cfg(feature = "dig2-station-probe")]
3480        if let Some(guard) = self.browser_station_lease_guard.take() {
3481            guard.retain();
3482        }
3483    }
3484}
3485
3486/// Port: everything product-side that must be prepared before a session is
3487/// bound and started (station lease, harness MCP overlay, materialization,
3488/// environment selection, network overlays, launch overlay).
3489trait SpawnPreparer {
3490    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3491    /// Select the native launch profile for an environment-profile session.
3492    fn select_environment(
3493        &self,
3494        instance_id: AgentInstanceId,
3495        provider: &AgentId,
3496        mode: SessionMode,
3497        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3498    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3499    /// Install a prepared launch overlay for the instance.
3500    fn install_launch_overlay(
3501        &self,
3502        instance_id: AgentInstanceId,
3503        overlay: PreparedNativeLaunchOverlay,
3504    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3505}
3506
3507impl SpawnPreparer for NodeShared {
3508    fn select_environment(
3509        &self,
3510        instance_id: AgentInstanceId,
3511        provider: &AgentId,
3512        mode: SessionMode,
3513        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3514    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3515        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3516    }
3517
3518    fn install_launch_overlay(
3519        &self,
3520        instance_id: AgentInstanceId,
3521        overlay: PreparedNativeLaunchOverlay,
3522    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3523        self.install_prepared_launch_overlay(instance_id, overlay)
3524    }
3525
3526    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3527        let SpawnPrepareRequest {
3528            instance_id,
3529            address,
3530            provider,
3531            mode,
3532            environment_profile,
3533            bundle,
3534            context,
3535            managed_authority,
3536            harness_mcp,
3537            approval_level,
3538            network_allowlist,
3539            browser_profile_id,
3540        } = request;
3541        // Track A follow-on: exclusive node-local BrowserStationLease after
3542        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3543        // never ImportSession / cookies on C2. Linux/feature-off: probe
3544        // already refused or stub; this block is cfg'd out when feature off.
3545        #[cfg(feature = "dig2-station-probe")]
3546        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3547            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3548            match acquire_lease(
3549                &self.browser_station_leases,
3550                profile_id,
3551                instance_id,
3552                unix_time_ms(),
3553            ) {
3554                Ok(guard) => Some(guard),
3555                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3556                    return Err(failure(
3557                        NodeFailureCode::BrowserStationProfileBusy,
3558                        "dig2browser station profile is already leased by another session",
3559                    ));
3560                }
3561                Err(BrowserStationLeaseError::CapacityExceeded) => {
3562                    return Err(failure(
3563                        NodeFailureCode::BrowserStationProfileBusy,
3564                        "dig2browser station profile lease capacity exhausted",
3565                    ));
3566                }
3567                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3568                    return Err(failure(
3569                        NodeFailureCode::BrowserStationProfileBusy,
3570                        "session already holds a dig2browser station profile lease",
3571                    ));
3572                }
3573            }
3574        } else {
3575            None
3576        };
3577        #[cfg(not(feature = "dig2-station-probe"))]
3578        let _ = browser_profile_id;
3579        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3580            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3581            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3582            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3583            // both read the identical overlay resolved below, one as child
3584            // environment, the other translated into one stdio MCP server
3585            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3586            // only, and nothing downstream reads a harness-MCP overlay for
3587            // that transport yet.
3588            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3589            {
3590                return Err(failure(
3591                    NodeFailureCode::BindingMismatch,
3592                    "harness MCP reservation does not match the exact PTY/ACP provider",
3593                ));
3594            }
3595            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3596            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3597                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3598            })?;
3599            // Every name below is the caller's, carried in the reservation's
3600            // launch description: which environment variables receive the
3601            // endpoint, token and helper program, what the MCP server is called,
3602            // which argv it gets and which stale variables are erased.
3603            let launch = prepared.launch();
3604            let mut env_entries = vec![
3605                (
3606                    OsString::from(launch.endpoint_env.as_str()),
3607                    prepared.endpoint().as_os_str().to_os_string(),
3608                ),
3609                (
3610                    OsString::from(launch.token_env.as_str()),
3611                    prepared.token().expose().into(),
3612                ),
3613                (
3614                    OsString::from(launch.program_env.as_str()),
3615                    prepared.helper_program().as_os_str().to_os_string(),
3616                ),
3617            ];
3618            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3619                env_entries.push(trace_entry);
3620            }
3621            let overlay = NativeMcpServerLaunchOverlay::new(
3622                provider.clone(),
3623                launch.server_name.as_str(),
3624                prepared.helper_program().as_os_str().to_os_string(),
3625                launch.args.iter().map(OsString::from).collect(),
3626                env_entries,
3627                launch.scrub_env.iter().map(OsString::from).collect(),
3628            ).map_err(|_| failure(
3629                NodeFailureCode::HarnessMcpUnavailable,
3630                "harness MCP launch overlay is unavailable",
3631            ))?;
3632            control
3633                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3634                .map_err(|_| failure(
3635                    NodeFailureCode::HarnessMcpUnavailable,
3636                    "harness MCP launch overlay could not be installed",
3637                ))?;
3638            // State-change line: the overlay is installed and will be read
3639            // back by the PTY child's environment or, for ACP,
3640            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3641            // time -- no secrets here, only which instance/provider/mode
3642            // now carries a harness-MCP door.
3643            tracing::info!(
3644                node_id = %self.node_id,
3645                instance_id = ?instance_id,
3646                provider = %provider,
3647                mode = ?mode,
3648                "harness MCP overlay installed for spawn",
3649            );
3650            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3651        } else {
3652            None
3653        };
3654        let prepared_materialization = self.prepare_session_materialization(
3655            &address,
3656            &provider,
3657            mode,
3658            environment_profile,
3659            bundle,
3660            context,
3661            managed_authority.cloned(),
3662        )?;
3663        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3664            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3665            None => (None, None),
3666        };
3667        let materialization_id = materialization_guard
3668            .as_ref()
3669            .and_then(SessionMaterializationGuard::id)
3670            .cloned();
3671        let mut environment_selection = SpawnPreparer::select_environment(self, 
3672            instance_id,
3673            &provider,
3674            mode,
3675            environment_profile,
3676        )?;
3677        // Station network overlays: Codex `-c network_access` and/or Claude
3678        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3679        let provider_home = materialization_guard
3680            .as_ref()
3681            .and_then(SessionMaterializationGuard::provider_home);
3682        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3683            &provider,
3684            mode,
3685            approval_level,
3686            network_allowlist,
3687            provider_home,
3688        )?;
3689        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3690            (prepared, args) if args.is_empty() => prepared,
3691            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3692                overlay.append_extra_args(args).map_err(|_| {
3693                    failure(
3694                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3695                        "network allowlist overlay could not merge with instance launch args",
3696                    )
3697                })?;
3698                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3699            }
3700            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3701                Some(PreparedNativeLaunchOverlay::Instance(
3702                    overlay
3703                        .into_instance_with_extra_args(args)
3704                        .map_err(|_| {
3705                            failure(
3706                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3707                                "network allowlist overlay could not merge with environment overlay",
3708                            )
3709                        })?,
3710                ))
3711            }
3712            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3713                NativeInstanceLaunchOverlay::new(
3714                    provider.clone(),
3715                    match mode {
3716                        SessionMode::Pty => TransportKind::Pty,
3717                        SessionMode::Acp => TransportKind::Acp,
3718                        SessionMode::Inline => TransportKind::Pipe,
3719                    },
3720                    Vec::new(),
3721                    args,
3722                )
3723                .map_err(|_| {
3724                    failure(
3725                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3726                        "network allowlist overlay is invalid for this transport",
3727                    )
3728                })?,
3729            )),
3730        };
3731        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3732        // overlay or appended to the overlay the spawn already carries.
3733        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3734            (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3735                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3736                match accounts.provision(m4a_provider, &session_key) {
3737                    Ok(account) => {
3738                        let identity = account.env_mutations();
3739                        let merged = match environment_overlay {
3740                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3741                                .map(PreparedNativeLaunchOverlay::Instance),
3742                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3743                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3744                            }
3745                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3746                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3747                            }
3748                        }
3749                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3750                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3751                        self.m4a_live
3752                            .lock()
3753                            .unwrap_or_else(|p| p.into_inner())
3754                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3755                        Some(merged)
3756                    }
3757                    Err(error) => {
3758                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3759                        environment_overlay
3760                    }
3761                }
3762            }
3763            (SessionMode::Pty, Some(_), None) => {
3764                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3765                environment_overlay
3766            }
3767            _ => environment_overlay,
3768        };
3769        let mut instance_overlay = environment_overlay
3770            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3771            .transpose()?
3772            .flatten();
3773        Ok(PreparedSpawn {
3774            instance_overlay,
3775            environment_selection,
3776            materialization_guard,
3777            harness_mcp_overlay,
3778            #[cfg(feature = "dig2-station-probe")]
3779            browser_station_lease_guard,
3780            materialization_id,
3781        })
3782    }
3783}
3784
3785struct SessionRecords {
3786    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3787}
3788
3789struct SpawnIdempotencyCache {
3790    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3791}
3792
3793struct SpawnIdempotencyEntry {
3794    value: SpawnIdempotencyValue,
3795    expires_at: Instant,
3796}
3797
3798enum SpawnIdempotencyValue {
3799    Standard {
3800        spec: SpawnSpec,
3801        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3802    },
3803    Managed {
3804        request: ManagedWorktreeSpawnRequest,
3805        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3806    },
3807    HarnessMcp,
3808}
3809
3810enum DurableManagedSpawnReplayDecision {
3811    Reserved,
3812    PendingLinked,
3813    Committed(ManagedWorktreeSpawnReceipt),
3814}
3815
3816impl SpawnIdempotencyValue {
3817    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3818        match self {
3819            Self::Standard { result, .. } => result
3820                .as_ref()
3821                .ok()
3822                .and_then(|receipt| receipt.context.as_ref()),
3823            Self::Managed { result, .. } => result
3824                .as_ref()
3825                .ok()
3826                .and_then(|receipt| receipt.spawn.context.as_ref()),
3827            Self::HarnessMcp => None,
3828        }
3829        .is_some_and(|receipt| &receipt.id == context_id)
3830    }
3831}
3832
3833#[derive(Clone, Copy)]
3834struct ControllerLease {
3835    connection_id: u64,
3836    expires_at: Instant,
3837}
3838
3839struct NodeEventHistory {
3840    last_sequence: u64,
3841    replay_floor_sequence: u64,
3842    events: VecDeque<NodeEventEnvelope>,
3843    record_providers: BTreeMap<SessionRecordId, AgentId>,
3844    removed_record_providers: BTreeMap<u64, AgentId>,
3845}
3846
3847impl NodeEventHistory {
3848    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3849        Self {
3850            last_sequence: 0,
3851            replay_floor_sequence: 1,
3852            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3853            record_providers,
3854            removed_record_providers: BTreeMap::new(),
3855        }
3856    }
3857}
3858
3859impl NodeShared {
3860    fn new_with_incarnation(
3861        handle: Gate4AgentHandle,
3862        access_token: String,
3863        node_id: NodeId,
3864        incarnation_id: NodeIncarnationId,
3865        workspaces: Vec<WorkspaceConfig>,
3866        enabled_providers: Vec<AgentId>,
3867        provider_runtime_statuses: ProviderRuntimeStatuses,
3868        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3869        provider_contracts: Vec<ProviderContractSupport>,
3870        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3871        spawn_profiles: SpawnProfileRegistry,
3872        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3873        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3874        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3875        state_path: Option<PathBuf>,
3876        records: Vec<ManagedSessionRecord>,
3877        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3878        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3879        materializations: Vec<MaterializationOwnershipRecord>,
3880        persistence_warning: Option<String>,
3881        input_settle_timeout_ms: u64,
3882    ) -> Self {
3883        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3884        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3885        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3886        let (harness_mcp_proxy_event_tx, _) =
3887            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3888        let record_providers = records
3889            .iter()
3890            .map(|record| (record.record_id.clone(), record.provider.clone()))
3891            .collect();
3892        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3893            .iter()
3894            .map(|workspace| {
3895                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3896            })
3897            .collect();
3898        for lease in &managed_worktrees {
3899            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3900        }
3901        let managed_worktree_profiles = workspaces.iter()
3902            .map(|workspace| (
3903                workspace.workspace_id.clone(),
3904                workspace.managed_worktree_profiles.clone(),
3905            ))
3906            .collect();
3907        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3908            managed_worktrees,
3909            managed_worktree_tombstones,
3910        ).expect("durable managed worktree registry was validated while loading");
3911        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3912        managed_worktrees.reattach_record_holders(
3913            &managed_worktree_record_holders(&records),
3914            unix_time_ms(),
3915        );
3916        Self {
3917            handle,
3918            access_token,
3919            node_id,
3920            incarnation_id,
3921            started_at_unix_ms: std::time::SystemTime::now()
3922                .duration_since(std::time::UNIX_EPOCH)
3923                .unwrap_or_default()
3924                .as_millis()
3925                .min(u64::MAX as u128) as u64,
3926            workspaces: RwLock::new(
3927                workspaces
3928                    .into_iter()
3929                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3930                    .collect(),
3931            ),
3932            worktree_service_modes,
3933            managed_worktree_profiles,
3934            managed_worktrees: Mutex::new(managed_worktrees),
3935            enabled_providers,
3936            provider_runtime_statuses,
3937            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3938            provider_runtime_monitor,
3939            provider_contracts,
3940            provider_adapter_contracts,
3941            spawn_profiles,
3942            environment_profiles: RwLock::new(BTreeMap::new()),
3943            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3944            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3945            #[cfg(feature = "dig2-station-probe")]
3946            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3947            bundle_catalog: RwLock::new(BundleCatalog::default()),
3948            delivery_store: Mutex::new(None),
3949            context_pack_store: Mutex::new(None),
3950            context_catalog: RwLock::new(ContextPackCatalog::default()),
3951            native_launch_profile_control,
3952            harness_mcp_registry: None,
3953            native_session_catalog,
3954            session_environment_materializer,
3955            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
3956            m4a_live: Mutex::new(BTreeMap::new()),
3957            materializations: Mutex::new(
3958                materializations
3959                    .into_iter()
3960                    .map(|record| (record.id().clone(), record))
3961                    .collect(),
3962            ),
3963            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3964                entries: BTreeMap::new(),
3965            }),
3966            managed_spawn_replays: Mutex::new(BTreeMap::new()),
3967            controller: Mutex::new(None),
3968            history: Mutex::new(NodeEventHistory::new(record_providers)),
3969            event_tx,
3970            terminal_event_tx,
3971            agent_stream_event_tx,
3972            harness_mcp_proxy_event_tx,
3973            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3974            next_connection_id: AtomicU64::new(1),
3975            next_instance_id: AtomicU64::new(1),
3976            next_command_id: AtomicU64::new(1),
3977            shutdown: AtomicBool::new(false),
3978            shutdown_notify: Notify::new(),
3979            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3980            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3981            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3982            mutation_gate: AsyncMutex::new(()),
3983            session_bindings: Mutex::new(BTreeMap::new()),
3984            session_records: Mutex::new(SessionRecords {
3985                records: records
3986                    .into_iter()
3987                    .map(|record| (record.record_id.clone(), record))
3988                    .collect(),
3989            }),
3990            state_path,
3991            persistence_error: RwLock::new(
3992                persistence_warning
3993                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
3994            ),
3995            state_transaction: Mutex::new(()),
3996            input_settle_timeout_ms,
3997            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3998            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3999            shell_efficiency_profile: OnceLock::new(),
4000            native_session_gauge: AtomicUsize::new(0),
4001            connection_loop_iterations: AtomicU64::new(0),
4002            connection_events_sent: AtomicU64::new(0),
4003            rejected_commands_total: AtomicU64::new(0),
4004            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4005            drive_loop_iterations_total: AtomicU64::new(0),
4006            drive_loop_iterations_idle: AtomicU64::new(0),
4007            session_record_retention: SessionRecordRetentionConfig::default(),
4008            retired_records_total: AtomicU64::new(0),
4009            #[cfg(feature = "fixture")]
4010            fixture_semantic_hook_policy: false,
4011            #[cfg(feature = "fixture")]
4012            fixture_spawn_managed_worktree_v2_failure_probe:
4013                SpawnManagedWorktreeV2FailureProbe::default(),
4014        }
4015    }
4016
4017    #[cfg(test)]
4018    fn new(
4019        handle: Gate4AgentHandle,
4020        access_token: String,
4021        node_id: NodeId,
4022        workspaces: Vec<WorkspaceConfig>,
4023        enabled_providers: Vec<AgentId>,
4024    ) -> Self {
4025        Self::new_with_incarnation(
4026            handle,
4027            access_token,
4028            node_id,
4029            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4030            workspaces,
4031            enabled_providers,
4032            ProviderRuntimeStatuses::default(),
4033            None,
4034            Vec::new(),
4035            Vec::new(),
4036            SpawnProfileRegistry::default(),
4037            None,
4038            None,
4039            None,
4040            None,
4041            Vec::new(),
4042            Vec::new(),
4043            Vec::new(),
4044            Vec::new(),
4045            None,
4046            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4047        )
4048    }
4049
4050    fn persistence_error(&self) -> Option<String> {
4051        self.persistence_error
4052            .read()
4053            .unwrap_or_else(|poisoned| poisoned.into_inner())
4054            .clone()
4055    }
4056
4057    async fn admit_provider_runtime(
4058        &self,
4059        provider: &AgentId,
4060        requirement: ProviderRuntimeRequirement,
4061    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4062        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4063            let pipe_transport = monitor.supports_pipe_transport(provider);
4064            let acp_transport = monitor.supports_acp_transport(provider);
4065            let provider = provider.clone();
4066            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4067            let (status, admission) = timeout(
4068                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4069                refresh,
4070            )
4071            .await
4072            .map_err(|_| failure(
4073                NodeFailureCode::BackendBusy,
4074                "provider runtime probe exceeded its bounded deadline",
4075            ))?
4076            .map_err(|_| failure(
4077                NodeFailureCode::BackendOperationFailed,
4078                "provider runtime probe task failed",
4079            ))?;
4080            if let Some(status) = status {
4081                self
4082                    .provider_runtime_status_updates
4083                    .write()
4084                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4085                    .insert(status.provider().clone(), status);
4086            }
4087            admission.and_then(|policy| {
4088                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4089            })
4090        } else {
4091            // No live monitor means no catalog to consult -- there is
4092            // nothing here to confirm this provider declares a Pipe or ACP
4093            // transport, so Inline/Acp are rejected the same conservative
4094            // way an undeclared provider always was, rather than admitted
4095            // on trust.
4096            crate::provider_runtime::admit_status(
4097                &self.provider_runtime_statuses,
4098                provider,
4099                false,
4100                false,
4101                requirement,
4102            )
4103        };
4104        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4105        admission.map_err(|error| match error {
4106            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4107                NodeFailureCode::BackendOperationFailed,
4108                "provider launcher is unavailable",
4109            ),
4110            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4111                NodeFailureCode::UnsupportedCapability,
4112                "provider semantic capability is not verified",
4113            ),
4114            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4115                NodeFailureCode::BackendBusy,
4116                "provider runtime probe is already in progress",
4117            ),
4118        })
4119    }
4120
4121    /// Whether the catalog declares a Pipe transport for `provider` --
4122    /// the fact `require_policy` needs to admit or reject
4123    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4124    /// catalog to consult, so this stays conservative and rejects, matching
4125    /// `admit_provider_runtime`'s own no-monitor branch.
4126    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4127        match self.provider_runtime_monitor.as_deref() {
4128            Some(monitor) => monitor.supports_pipe_transport(provider),
4129            None => false,
4130        }
4131    }
4132
4133    /// Whether the catalog declares an ACP transport for `provider` -- the
4134    /// fact `require_policy` needs to admit or reject
4135    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4136    /// `provider_supports_pipe_transport` exactly, including the
4137    /// conservative no-monitor default.
4138    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4139        match self.provider_runtime_monitor.as_deref() {
4140            Some(monitor) => monitor.supports_acp_transport(provider),
4141            None => false,
4142        }
4143    }
4144
4145    /// Grants hook ingestion to a provider whose catalog entry declares a
4146    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4147    ///
4148    /// `admit_status`'s `RawPassthrough` arm hands back
4149    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4150    /// correct for everything it was written to answer, and wrong for hooks
4151    /// specifically. A hook event is not inferred from terminal behaviour,
4152    /// so it has nothing to do with whether a launcher probe verified this
4153    /// CLI version's PTY contract; the authority is the catalog declaring
4154    /// the adapter plus the ingress authenticating the route. Without this
4155    /// the node installs a provider's hooks, the CLI faithfully calls them,
4156    /// and every event is refused -- which is exactly the state grok was in.
4157    ///
4158    /// Applied to both admission paths so the monitor's own derivation and
4159    /// this coarse fallback cannot disagree about the same provider.
4160    fn admit_declared_hook_semantics(
4161        &self,
4162        provider: &AgentId,
4163        policy: ProviderRuntimePolicy,
4164    ) -> ProviderRuntimePolicy {
4165        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4166            return policy;
4167        }
4168        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4169            contract.provider == *provider && contract.family == AdapterFamily::Hook
4170        });
4171        if !declares_hook {
4172            return policy;
4173        }
4174        ProviderRuntimePolicy::new(
4175            policy.raw_pty_lifecycle,
4176            policy.semantic_readiness,
4177            policy.structured_prompt,
4178            policy.provider_session_identity,
4179            policy.semantic_resume,
4180            true,
4181        )
4182        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4183    }
4184
4185    /// Station network allowlist → provider launch overlay argv.
4186    ///
4187    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4188    ///   `provider_native` under Moderate (PTY or ACP).
4189    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4190    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4191    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4192    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4193    ///   no overlay.
4194    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4195    ///   empty overlay when no `provider_native`; invented native keys already
4196    ///   refuse at load / resolve.
4197    ///
4198    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4199    fn resolve_network_allowlist_overlay_args(
4200        &self,
4201        provider: &AgentId,
4202        mode: SessionMode,
4203        approval_level: ApprovalLevel,
4204        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4205        provider_home: Option<&Path>,
4206    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4207        let Some(allowlist_id) = network_allowlist else {
4208            return Ok(Vec::new());
4209        };
4210        let catalog = self
4211            .network_allowlist_catalog
4212            .read()
4213            .unwrap_or_else(|poisoned| poisoned.into_inner());
4214        let Some(entry) = catalog.get(allowlist_id) else {
4215            return Err(failure(
4216                NodeFailureCode::UnknownNetworkAllowlist,
4217                "spawn network allowlist is unavailable on this node",
4218            ));
4219        };
4220
4221        // Claude: settings-shaped overlay from permits (not provider_native).
4222        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4223            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4224                |message| {
4225                    failure(
4226                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4227                        &message,
4228                    )
4229                },
4230            )?;
4231            if !matches!(mode, SessionMode::Pty) {
4232                return Err(failure(
4233                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4234                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4235                ));
4236            }
4237            let Some(home) = provider_home else {
4238                return Err(failure(
4239                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4240                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4241                ));
4242            };
4243            let domains =
4244                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4245            if domains.is_empty() {
4246                return Err(failure(
4247                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4248                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4249                ));
4250            }
4251            let settings_path = home.join(
4252                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4253            );
4254            let json =
4255                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4256            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4257                |_| {
4258                    failure(
4259                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4260                        "Claude station network settings sidecar could not be written under ProviderHome",
4261                    )
4262                },
4263            )?;
4264            let overlay =
4265                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4266            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4267        }
4268
4269        let Some(native) = entry.provider_native.as_ref() else {
4270            return Ok(Vec::new());
4271        };
4272        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4273            provider.as_str(),
4274            approval_level,
4275            native,
4276        )
4277        .map_err(|message| {
4278            failure(
4279                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4280                &message,
4281            )
4282        })?;
4283        if overlay.is_empty() {
4284            return Ok(Vec::new());
4285        }
4286        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4287            return Err(failure(
4288                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4289                "Codex network_access -c overlay requires PTY or ACP transport",
4290            ));
4291        }
4292        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4293    }
4294
4295    /// Backward-compatible alias used by Codex-focused unit tests.
4296    fn resolve_codex_network_access_overlay_args(
4297        &self,
4298        provider: &AgentId,
4299        mode: SessionMode,
4300        approval_level: ApprovalLevel,
4301        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4302    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4303        self.resolve_network_allowlist_overlay_args(
4304            provider,
4305            mode,
4306            approval_level,
4307            network_allowlist,
4308            None,
4309        )
4310    }
4311
4312    fn resolve_environment_profile(
4313        &self,
4314        resolved: &ResolvedSpawnSpec,
4315    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4316        // Station network allowlist catalog enforce (plan
4317        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4318        // catalog — unknown ids refuse rather than silent ambient. Runs even
4319        // when no environment_profile_id is present so a bare allowlist still
4320        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4321        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4322            let catalog = self
4323                .network_allowlist_catalog
4324                .read()
4325                .unwrap_or_else(|poisoned| poisoned.into_inner());
4326            let Some(entry) = catalog.get(allowlist_id) else {
4327                return Err(failure(
4328                    NodeFailureCode::UnknownNetworkAllowlist,
4329                    "spawn network allowlist is unavailable on this node",
4330                ));
4331            };
4332            // Track B: refuse provider-native mappings this provider cannot
4333            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4334            if let Some(native) = entry.provider_native.as_ref() {
4335                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4336                    resolved.provider.as_str(),
4337                    resolved.approval_level,
4338                    native,
4339                ) {
4340                    return Err(failure(
4341                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4342                        &message,
4343                    ));
4344                }
4345            }
4346        }
4347        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4348        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4349        // cheap named-pipe path reachability must succeed (Windows
4350        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4351        // NodeFailureCode. Runs even without environment_profile_id (same
4352        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4353        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4354        // acquired later in spawn_session_with_deadline (after instance id).
4355        #[cfg(feature = "dig2-station-probe")]
4356        if resolved.browser_profile_id.is_some() {
4357            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4358            match probe_station_reachable() {
4359                Ok(()) => {}
4360                Err(StationProbeError::UnsupportedPlatform)
4361                | Err(StationProbeError::InvalidPipeSuffix) => {
4362                    return Err(failure(
4363                        NodeFailureCode::BrowserStationProbeUnavailable,
4364                        "dig2browser station probe unavailable on this platform",
4365                    ));
4366                }
4367                Err(StationProbeError::Unreachable) => {
4368                    return Err(failure(
4369                        NodeFailureCode::BrowserStationUnreachable,
4370                        "dig2browser station named-pipe is unreachable",
4371                    ));
4372                }
4373            }
4374        }
4375        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4376            return Ok(None);
4377        };
4378        let profiles = self
4379            .environment_profiles
4380            .read()
4381            .unwrap_or_else(|poisoned| poisoned.into_inner());
4382        let binding = profiles.get(profile_id).ok_or_else(|| {
4383            failure(
4384                NodeFailureCode::UnknownEnvironmentProfile,
4385                "spawn environment profile is unavailable on this node",
4386            )
4387        })?;
4388        if binding.provider != resolved.provider
4389            || binding.native_profile_id(resolved.mode).is_none()
4390        {
4391            return Err(failure(
4392                NodeFailureCode::EnvironmentProfileBindingMismatch,
4393                "spawn environment profile does not match the provider and mode",
4394            ));
4395        }
4396        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4397        // after catalog + optional dig2 probe above. Empty/whitespace ids
4398        // already refuse at Spawn*Id construction.
4399        Ok(Some(ResolvedEnvironmentProfileReceipt {
4400            profile_id: binding.id.clone(),
4401            profile_revision: binding.revision.clone(),
4402            network_allowlist: resolved.network_allowlist.clone(),
4403            browser_profile_id: resolved.browser_profile_id.clone(),
4404        }))
4405    }
4406
4407    fn resolve_bundle(
4408        &self,
4409        resolved: &ResolvedSpawnSpec,
4410        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4411    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4412        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4413            return Ok(None);
4414        };
4415        let catalog = self.bundle_catalog
4416            .read()
4417            .unwrap_or_else(|poisoned| poisoned.into_inner());
4418        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4419                failure(
4420                    NodeFailureCode::UnknownBundle,
4421                    "spawn bundle is unavailable on this node",
4422                )
4423            })?;
4424        self.bundle_layout(
4425            &resolved.provider,
4426            resolved.mode,
4427            environment_profile,
4428            bundle,
4429        )?;
4430        Ok(Some(bundle.receipt()))
4431    }
4432
4433    fn begin_delivery_stage(
4434        &self,
4435        manifest: DeliveryBundleManifestV2,
4436    ) -> Result<NodeResponse, NodeFailure> {
4437        let mut store = self
4438            .delivery_store
4439            .lock()
4440            .unwrap_or_else(|poisoned| poisoned.into_inner());
4441        let store = store.as_mut().ok_or_else(|| {
4442            failure(
4443                NodeFailureCode::DeliveryStageStorageFailed,
4444                "node delivery store is unavailable",
4445            )
4446        })?;
4447        let begun = store.begin(manifest).map_err(delivery_failure)?;
4448        Ok(NodeResponse::DeliveryStageBegun {
4449            stage_id: begun.stage_id,
4450            manifest_digest: begun.manifest_digest,
4451            missing_blobs: begun.missing_blobs,
4452        })
4453    }
4454
4455    fn put_delivery_blob_chunk(
4456        &self,
4457        stage_id: DeliveryStageId,
4458        blob_digest: DeliveryBlobDigestV1,
4459        offset: u64,
4460        chunk_hex: DeliveryBlobChunkHexV1,
4461    ) -> Result<NodeResponse, NodeFailure> {
4462        let mut store = self
4463            .delivery_store
4464            .lock()
4465            .unwrap_or_else(|poisoned| poisoned.into_inner());
4466        let store = store.as_mut().ok_or_else(|| {
4467            failure(
4468                NodeFailureCode::DeliveryStageStorageFailed,
4469                "node delivery store is unavailable",
4470            )
4471        })?;
4472        let accepted = store
4473            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4474            .map_err(delivery_failure)?;
4475        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4476            stage_id: accepted.stage_id,
4477            blob_digest: accepted.blob_digest,
4478            next_offset: accepted.next_offset,
4479        })
4480    }
4481
4482    fn commit_delivery_stage(
4483        &self,
4484        stage_id: DeliveryStageId,
4485    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4486        let mut store = self
4487            .delivery_store
4488            .lock()
4489            .unwrap_or_else(|poisoned| poisoned.into_inner());
4490        let store = store.as_mut().ok_or_else(|| {
4491            failure(
4492                NodeFailureCode::DeliveryStageStorageFailed,
4493                "node delivery store is unavailable",
4494            )
4495        })?;
4496        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4497        let mut catalog = self
4498            .bundle_catalog
4499            .write()
4500            .unwrap_or_else(|poisoned| poisoned.into_inner());
4501        let mut next_catalog = catalog.clone();
4502        next_catalog
4503            .insert_idempotent(prepared.bundle().clone())
4504            .map_err(|_| {
4505                failure(
4506                    NodeFailureCode::DeliveryStageConflict,
4507                    "delivery bundle identity conflicts with installed content",
4508                )
4509            })?;
4510        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4511        *catalog = next_catalog;
4512        Ok(receipt)
4513    }
4514
4515    fn abort_delivery_stage(
4516        &self,
4517        stage_id: &DeliveryStageId,
4518    ) -> Result<(), NodeFailure> {
4519        let mut store = self
4520            .delivery_store
4521            .lock()
4522            .unwrap_or_else(|poisoned| poisoned.into_inner());
4523        let store = store.as_mut().ok_or_else(|| {
4524            failure(
4525                NodeFailureCode::DeliveryStageStorageFailed,
4526                "node delivery store is unavailable",
4527            )
4528        })?;
4529        store.abort(stage_id).map_err(delivery_failure)
4530    }
4531
4532    fn resolve_context(
4533        &self,
4534        resolved: &ResolvedSpawnSpec,
4535    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4536        let Some(context_id) = resolved.context_id.as_ref() else {
4537            return Ok(None);
4538        };
4539        self.context_catalog
4540            .read()
4541            .unwrap_or_else(|poisoned| poisoned.into_inner())
4542            .get(context_id)
4543            .map(|context| context.receipt().clone())
4544            .ok_or_else(|| {
4545                failure(
4546                    NodeFailureCode::UnknownContextPack,
4547                    "spawn context pack is unavailable on this node",
4548                )
4549            })
4550            .map(Some)
4551    }
4552
4553    fn bundle_layout(
4554        &self,
4555        provider: &AgentId,
4556        mode: SessionMode,
4557        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4558        bundle: &NodeBundle,
4559    ) -> Result<BundleProviderLayout, NodeFailure> {
4560        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4561            failure(
4562                NodeFailureCode::BundleBindingMismatch,
4563                "spawn bundle does not match the provider and mode",
4564            )
4565        })?;
4566        if layout != BundleProviderLayout::Codex {
4567            return Ok(layout);
4568        }
4569        let profile = environment_profile.and_then(|receipt| {
4570            let profiles = self
4571                .environment_profiles
4572                .read()
4573                .unwrap_or_else(|poisoned| poisoned.into_inner());
4574            let binding = profiles.get(&receipt.profile_id)?;
4575            if binding.revision != receipt.profile_revision
4576                || &binding.provider != provider
4577                || binding.native_profile_id(mode).is_none()
4578            {
4579                return None;
4580            }
4581            self.environment_materialization_profiles
4582                .read()
4583                .unwrap_or_else(|poisoned| poisoned.into_inner())
4584                .get(&receipt.profile_id)
4585                .cloned()
4586        });
4587        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4588            return Err(failure(
4589                NodeFailureCode::BundleBindingMismatch,
4590                "Codex bundle requires an exact isolated CODEX_HOME profile",
4591            ));
4592        }
4593        Ok(layout)
4594    }
4595
4596    fn select_environment_profile(
4597        &self,
4598        instance_id: AgentInstanceId,
4599        provider: &AgentId,
4600        mode: SessionMode,
4601        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4602    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4603        let Some(environment_profile) = environment_profile else {
4604            return Ok(None);
4605        };
4606        let native_profile_id = {
4607            let profiles = self
4608                .environment_profiles
4609                .read()
4610                .unwrap_or_else(|poisoned| poisoned.into_inner());
4611            let binding = profiles
4612                .get(&environment_profile.profile_id)
4613                .ok_or_else(|| {
4614                    failure(
4615                        NodeFailureCode::UnknownEnvironmentProfile,
4616                        "session environment profile is unavailable on this node",
4617                    )
4618                })?;
4619            if &binding.revision != &environment_profile.profile_revision
4620                || &binding.provider != provider
4621            {
4622                return Err(failure(
4623                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4624                    "session environment profile revision or provider changed",
4625                ));
4626            }
4627            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4628                failure(
4629                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4630                    "session environment profile does not support this mode",
4631                )
4632            })?
4633        };
4634        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4635            failure(
4636                NodeFailureCode::BackendOperationFailed,
4637                "native environment profile controller is unavailable",
4638            )
4639        })?;
4640        control
4641            .select_native_launch_profile(instance_id, native_profile_id)
4642            .map_err(|_| {
4643                failure(
4644                    NodeFailureCode::BackendOperationFailed,
4645                    "native environment profile selection failed",
4646                )
4647            })?;
4648        Ok(Some(NativeEnvironmentSelectionGuard {
4649            control: Some(control),
4650            instance_id,
4651        }))
4652    }
4653
4654    fn install_prepared_launch_overlay(
4655        &self,
4656        instance_id: AgentInstanceId,
4657        overlay: PreparedNativeLaunchOverlay,
4658    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4659        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4660            failure(
4661                NodeFailureCode::BackendOperationFailed,
4662                "native launch profile controller is unavailable",
4663            )
4664        })?;
4665        match overlay {
4666            PreparedNativeLaunchOverlay::Environment(overlay) => {
4667                control
4668                    .install_native_launch_environment_overlay(instance_id, overlay)
4669                    .map_err(|_| failure(
4670                        NodeFailureCode::BackendOperationFailed,
4671                        "session environment overlay installation failed",
4672                    ))?;
4673                Ok(None)
4674            }
4675            PreparedNativeLaunchOverlay::Instance(overlay) => {
4676                control
4677                    .install_native_instance_launch_overlay(instance_id, overlay)
4678                    .map_err(|_| failure(
4679                        NodeFailureCode::BundleBindingMismatch,
4680                        "session bundle launch overlay installation failed",
4681                    ))?;
4682                Ok(Some(NativeInstanceOverlayGuard {
4683                    control: Some(control),
4684                    instance_id,
4685                }))
4686            }
4687        }
4688    }
4689
4690    fn environment_materialization_profile(
4691        &self,
4692        environment_profile: &ResolvedEnvironmentProfileReceipt,
4693    ) -> Option<NodeSessionMaterializationProfile> {
4694        self.environment_materialization_profiles
4695            .read()
4696            .unwrap_or_else(|poisoned| poisoned.into_inner())
4697            .get(&environment_profile.profile_id)
4698            .cloned()
4699    }
4700
4701    fn materialization_profile(
4702        &self,
4703        provider: &AgentId,
4704        mode: SessionMode,
4705        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4706        bundle: Option<&ResolvedBundleReceipt>,
4707        context: Option<&ResolvedContextPackReceipt>,
4708    ) -> Result<Option<(
4709        NodeSessionMaterializationProfile,
4710        bool,
4711        Option<BundleProviderLayout>,
4712    )>, NodeFailure> {
4713        let environment = environment_profile
4714            .and_then(|receipt| self.environment_materialization_profile(receipt));
4715        let (bundle, bundle_layout) = match bundle {
4716            Some(receipt) => {
4717                let catalog = self.bundle_catalog.read()
4718                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4719                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4720                    NodeFailureCode::UnknownBundle,
4721                    "session bundle is unavailable on this node",
4722                ))?;
4723                if installed.receipt() != *receipt {
4724                    return Err(failure(
4725                        NodeFailureCode::BundleBindingMismatch,
4726                        "session bundle revision or digest changed",
4727                    ));
4728                }
4729                let layout = self.bundle_layout(
4730                    provider,
4731                    mode,
4732                    environment_profile,
4733                    installed,
4734                )?;
4735                (Some(installed.clone()), Some(layout))
4736            }
4737            None => (None, None),
4738        };
4739        let has_environment_overlay = environment.is_some() || context.is_some();
4740        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4741            (None, None, None) => None,
4742            (Some(profile), None, None) => Some(Ok(profile)),
4743            (None, Some(bundle), Some(layout)) => {
4744                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4745            }
4746            (Some(profile), Some(bundle), Some(layout)) => {
4747                Some(profile.with_bundle(bundle, layout))
4748            }
4749            _ => {
4750                return Err(failure(
4751                    NodeFailureCode::BundleBindingMismatch,
4752                    "session bundle provider layout is unavailable",
4753                ));
4754            }
4755        };
4756        let profile = profile
4757            .transpose()
4758            .map_err(|_| failure(
4759                NodeFailureCode::BundleMaterializationFailed,
4760                "session bundle materialization profile is invalid",
4761            ))?;
4762        let profile = match (profile, context) {
4763            (Some(profile), Some(receipt)) => {
4764                let catalog = self.context_catalog
4765                    .read()
4766                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4767                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4768                    NodeFailureCode::UnknownContextPack,
4769                    "session context pack is unavailable on this node",
4770                ))?;
4771                if pack.receipt() != receipt {
4772                    return Err(failure(
4773                        NodeFailureCode::ContextPackMaterializationFailed,
4774                        "session context pack identity changed",
4775                    ));
4776                }
4777                profile.with_context(pack)
4778            }
4779            (None, Some(receipt)) => {
4780                let catalog = self.context_catalog
4781                    .read()
4782                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4783                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4784                    NodeFailureCode::UnknownContextPack,
4785                    "session context pack is unavailable on this node",
4786                ))?;
4787                if pack.receipt() != receipt {
4788                    return Err(failure(
4789                        NodeFailureCode::ContextPackMaterializationFailed,
4790                        "session context pack identity changed",
4791                    ));
4792                }
4793                NodeSessionMaterializationProfile::from_context(pack)
4794            }
4795            (Some(profile), None) => Ok(profile),
4796            (None, None) => return Ok(None),
4797        }
4798        .map_err(|_| failure(
4799            NodeFailureCode::ContextPackMaterializationFailed,
4800            "session context pack materialization profile is invalid",
4801        ))?;
4802        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4803    }
4804
4805    fn prepare_session_materialization<'a>(
4806        &'a self,
4807        address: &SessionAddress,
4808        provider: &AgentId,
4809        mode: SessionMode,
4810        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4811        bundle: Option<&ResolvedBundleReceipt>,
4812        context: Option<&ResolvedContextPackReceipt>,
4813        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4814    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4815        let Some((profile, has_environment_overlay, bundle_layout)) =
4816            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4817        else {
4818            return Ok(None);
4819        };
4820        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4821            failure(
4822                NodeFailureCode::BackendOperationFailed,
4823                "session environment materializer is unavailable",
4824            )
4825        })?;
4826        let materialization_failure_code = if context.is_some() {
4827            NodeFailureCode::ContextPackMaterializationFailed
4828        } else if bundle.is_some() {
4829            NodeFailureCode::BundleMaterializationFailed
4830        } else {
4831            NodeFailureCode::BackendOperationFailed
4832        };
4833        let id = MaterializationId::new(format!(
4834            "mat-{}-{}-{}",
4835            self.incarnation_id,
4836            address.session.instance_id.0,
4837            address.session.generation.0,
4838        ))
4839        .map_err(|_| {
4840            failure(
4841                NodeFailureCode::BackendOperationFailed,
4842                "session environment materialization identity failed",
4843            )
4844        })?;
4845        let owner = MaterializationOwner::Session {
4846            incarnation_id: self.incarnation_id,
4847            instance_id: address.session.instance_id,
4848            generation: address.session.generation,
4849        };
4850        let mut ownership = materializer
4851            .begin(
4852                id.clone(),
4853                environment_profile.cloned(),
4854                bundle.cloned(),
4855                context.cloned(),
4856                owner,
4857                managed_lease_id,
4858                &profile,
4859                unix_time_ms(),
4860            )
4861            .map_err(|_| {
4862                failure(
4863                    materialization_failure_code,
4864                    "session materialization preparation failed",
4865                )
4866            })?;
4867        {
4868            let _transaction = self
4869                .state_transaction
4870                .lock()
4871                .unwrap_or_else(|poisoned| poisoned.into_inner());
4872            let duplicate = self
4873                .materializations
4874                .lock()
4875                .unwrap_or_else(|poisoned| poisoned.into_inner())
4876                .insert(id.clone(), ownership.clone());
4877            if duplicate.is_some() {
4878                return Err(failure(
4879                    NodeFailureCode::BackendOperationFailed,
4880                    "session environment materialization identity conflict",
4881                ));
4882            }
4883            if let Err(error) = self.persist_state_locked() {
4884                self.materializations
4885                    .lock()
4886                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4887                    .remove(&id);
4888                return Err(persistence_failure(error));
4889            }
4890        }
4891        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4892            Ok(environment) => environment,
4893            Err(_) => {
4894                let transaction = self
4895                    .state_transaction
4896                    .lock()
4897                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4898                self.materializations
4899                    .lock()
4900                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4901                    .insert(id.clone(), ownership.clone());
4902                if self.persist_state_locked().is_err() {
4903                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4904                }
4905                drop(transaction);
4906                if ownership.state() == MaterializationState::CleanupRequired {
4907                    let _ = self.cleanup_materialization(&id);
4908                }
4909                return Err(failure(
4910                    materialization_failure_code,
4911                    "session materialization failed",
4912                ));
4913            }
4914        };
4915        {
4916            let _transaction = self
4917                .state_transaction
4918                .lock()
4919                .unwrap_or_else(|poisoned| poisoned.into_inner());
4920            self.materializations
4921                .lock()
4922                .unwrap_or_else(|poisoned| poisoned.into_inner())
4923                .insert(id.clone(), ownership.clone());
4924            if let Err(error) = self.persist_state_locked() {
4925                drop(_transaction);
4926                let _ = self.cleanup_materialization(&id);
4927                return Err(persistence_failure(error));
4928            }
4929        }
4930        let guard = SessionMaterializationGuard {
4931            shared: self,
4932            id: Some(id),
4933            provider_home: Some(ownership.provider_home().to_path_buf()),
4934        };
4935        let bundle_arguments = match bundle {
4936            Some(receipt) => {
4937                let catalog = self.bundle_catalog.read()
4938                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4939                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4940                    NodeFailureCode::UnknownBundle,
4941                    "session bundle is unavailable on this node",
4942                ))?;
4943                if installed.receipt() != *receipt {
4944                    return Err(failure(
4945                        NodeFailureCode::BundleBindingMismatch,
4946                        "session bundle revision or digest changed",
4947                    ));
4948                }
4949                let layout = bundle_layout.ok_or_else(|| failure(
4950                    NodeFailureCode::BundleBindingMismatch,
4951                    "materialized bundle provider layout is unavailable",
4952                ))?;
4953                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4954                    failure(
4955                        NodeFailureCode::BundleMaterializationFailed,
4956                        "session bundle byte revalidation failed",
4957                    )
4958                })?;
4959                Some(bundle_launch_arguments(
4960                    layout,
4961                    ownership.bundle_root(),
4962                    ownership.provider_home(),
4963                ).map_err(|_| failure(
4964                    NodeFailureCode::BundleBindingMismatch,
4965                    "session bundle launch binding is invalid",
4966                ))?)
4967            }
4968            None => None,
4969        };
4970        if let Some(receipt) = context {
4971            materializer
4972                .revalidate_context(&ownership, receipt)
4973                .map_err(|_| failure(
4974                    NodeFailureCode::ContextPackMaterializationFailed,
4975                    "session context pack byte revalidation failed",
4976                ))?;
4977        }
4978        let transport = match mode {
4979            SessionMode::Pty => TransportKind::Pty,
4980            SessionMode::Inline => TransportKind::Pipe,
4981            SessionMode::Acp => TransportKind::Acp,
4982        };
4983        let overlay = if let Some(extra_args) = bundle_arguments {
4984            Some(PreparedNativeLaunchOverlay::Instance(
4985                NativeInstanceLaunchOverlay::new(
4986                    provider.clone(),
4987                    transport,
4988                    if has_environment_overlay {
4989                        environment
4990                    } else {
4991                        Vec::new()
4992                    },
4993                    extra_args,
4994                )
4995                .map_err(|_| {
4996                    failure(
4997                        NodeFailureCode::BundleBindingMismatch,
4998                        "session bundle launch overlay is invalid",
4999                    )
5000                })?,
5001            ))
5002        } else if has_environment_overlay {
5003            let context_only = environment_profile.is_none()
5004                && bundle.is_none()
5005                && context.is_some();
5006            let overlay = if context_only {
5007                NativeLaunchEnvironmentOverlay::new_context_root(
5008                    provider.clone(),
5009                    transport,
5010                    environment,
5011                )
5012                .map_err(|_| {
5013                    failure(
5014                        NodeFailureCode::ContextPackMaterializationFailed,
5015                        "context-only launch overlay is invalid",
5016                    )
5017                })?
5018            } else {
5019                NativeLaunchEnvironmentOverlay::new(
5020                    provider.clone(),
5021                    transport,
5022                    environment,
5023                )
5024                .map_err(|_| {
5025                    failure(
5026                        NodeFailureCode::BackendOperationFailed,
5027                        "session environment overlay is invalid",
5028                    )
5029                })?
5030            };
5031            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5032        } else {
5033            None
5034        };
5035        Ok(Some((overlay, guard)))
5036    }
5037
5038    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5039        let Some(mut ownership) = self
5040            .materializations
5041            .lock()
5042            .unwrap_or_else(|poisoned| poisoned.into_inner())
5043            .get(id)
5044            .cloned()
5045        else {
5046            return Ok(());
5047        };
5048        if ownership.state() != MaterializationState::CleanupRequired {
5049            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5050                failure(
5051                    NodeFailureCode::BackendOperationFailed,
5052                    "session environment cleanup state is invalid",
5053                )
5054            })?;
5055        }
5056        {
5057            let _transaction = self
5058                .state_transaction
5059                .lock()
5060                .unwrap_or_else(|poisoned| poisoned.into_inner());
5061            let previous = self
5062                .materializations
5063                .lock()
5064                .unwrap_or_else(|poisoned| poisoned.into_inner())
5065                .insert(id.clone(), ownership.clone());
5066            if let Err(error) = self.persist_state_locked() {
5067                let mut materializations = self
5068                    .materializations
5069                    .lock()
5070                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5071                if let Some(previous) = previous {
5072                    materializations.insert(id.clone(), previous);
5073                }
5074                return Err(persistence_failure(error));
5075            }
5076        }
5077        let cleanup = self
5078            .session_environment_materializer
5079            .as_ref()
5080            .ok_or(())
5081            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5082        if cleanup.is_err() {
5083            let _ = ownership.mark_recovery_required(unix_time_ms());
5084            self.materializations
5085                .lock()
5086                .unwrap_or_else(|poisoned| poisoned.into_inner())
5087                .insert(id.clone(), ownership);
5088            if self.persist_state().is_err() {
5089                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5090            }
5091            return Err(failure(
5092                NodeFailureCode::BackendOperationFailed,
5093                "session environment cleanup requires recovery",
5094            ));
5095        }
5096        let _transaction = self
5097            .state_transaction
5098            .lock()
5099            .unwrap_or_else(|poisoned| poisoned.into_inner());
5100        self.materializations
5101            .lock()
5102            .unwrap_or_else(|poisoned| poisoned.into_inner())
5103            .remove(id);
5104        if let Err(error) = self.persist_state_locked() {
5105            self.materializations
5106                .lock()
5107                .unwrap_or_else(|poisoned| poisoned.into_inner())
5108                .insert(id.clone(), ownership);
5109            return Err(persistence_failure(error));
5110        }
5111        Ok(())
5112    }
5113
5114    fn resolve_record_materialization(
5115        &self,
5116        record_id: &SessionRecordId,
5117        provider: &AgentId,
5118        mode: SessionMode,
5119        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5120        bundle: Option<&ResolvedBundleReceipt>,
5121        context: Option<&ResolvedContextPackReceipt>,
5122    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5123        let ownership = {
5124            let materializations = self
5125                .materializations
5126                .lock()
5127                .unwrap_or_else(|poisoned| poisoned.into_inner());
5128            let mut matches = materializations.values().filter(|record| {
5129                record.owner()
5130                    == &MaterializationOwner::Record {
5131                        record_id: record_id.clone(),
5132                    }
5133            });
5134            let ownership = matches.next().cloned();
5135            if matches.next().is_some() {
5136                return Err(failure(
5137                    NodeFailureCode::BackendOperationFailed,
5138                    "managed session has conflicting environment ownership",
5139                ));
5140            }
5141            ownership
5142        };
5143        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5144            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5145                failure(
5146                    NodeFailureCode::ContextPackMaterializationFailed,
5147                    "session context materializer is unavailable",
5148                )
5149            })?;
5150            let pack = materializer
5151                .revalidate_context(ownership, receipt)
5152                .map_err(|_| {
5153                    self.mark_materialization_recovery_required(ownership.id());
5154                    failure(
5155                        NodeFailureCode::ContextPackMaterializationFailed,
5156                        "managed session context pack revalidation failed",
5157                    )
5158                })?;
5159            self.context_catalog
5160                .write()
5161                .unwrap_or_else(|poisoned| poisoned.into_inner())
5162                .insert(pack)
5163                .map_err(|_| failure(
5164                    NodeFailureCode::ContextPackMaterializationFailed,
5165                    "managed session context pack catalog restore failed",
5166                ))?;
5167        }
5168        let materialization = self.materialization_profile(
5169            provider,
5170            mode,
5171            environment_profile,
5172            bundle,
5173            context,
5174        )?;
5175        match (ownership, materialization) {
5176            (None, None) => Ok(None),
5177            (None, Some(_)) | (Some(_), None) => Err(failure(
5178                NodeFailureCode::BackendOperationFailed,
5179                "managed session materialization is unavailable",
5180            )),
5181            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5182                if ownership.environment_profile() != environment_profile
5183                    || ownership.bundle() != bundle
5184                    || ownership.context() != context
5185                    || ownership.state() != MaterializationState::Ready
5186                {
5187                    self.mark_materialization_recovery_required(ownership.id());
5188                    return Err(failure(
5189                        NodeFailureCode::BackendOperationFailed,
5190                        "managed session environment ownership requires recovery",
5191                    ));
5192                }
5193                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5194                    failure(
5195                        NodeFailureCode::BackendOperationFailed,
5196                        "session environment materializer is unavailable",
5197                    )
5198                })?;
5199                if materializer.revalidate(&ownership).is_err() {
5200                    self.mark_materialization_recovery_required(ownership.id());
5201                    return Err(failure(
5202                        NodeFailureCode::BackendOperationFailed,
5203                        "managed session environment revalidation failed",
5204                    ));
5205                }
5206                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5207                    let catalog = self.bundle_catalog.read()
5208                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5209                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5210                        return Err(failure(
5211                            NodeFailureCode::UnknownBundle,
5212                            "managed session bundle is unavailable",
5213                        ));
5214                    };
5215                    let layout = bundle_layout.ok_or_else(|| failure(
5216                        NodeFailureCode::BundleBindingMismatch,
5217                        "record bundle provider layout is unavailable",
5218                    ))?;
5219                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5220                        drop(catalog);
5221                        self.mark_materialization_recovery_required(ownership.id());
5222                        return Err(failure(
5223                            NodeFailureCode::BundleMaterializationFailed,
5224                            "managed session bundle revalidation failed",
5225                        ));
5226                    }
5227                    Some(bundle_launch_arguments(
5228                        layout,
5229                        ownership.bundle_root(),
5230                        ownership.provider_home(),
5231                    ).map_err(|_| failure(
5232                        NodeFailureCode::BundleBindingMismatch,
5233                        "managed session bundle launch binding is invalid",
5234                    ))?)
5235                } else {
5236                    None
5237                };
5238                if !has_environment_materialization {
5239                    let overlay = bundle_arguments.map(|extra_args| {
5240                        NativeInstanceLaunchOverlay::new(
5241                            provider.clone(),
5242                            TransportKind::Pty,
5243                            Vec::new(),
5244                            extra_args,
5245                        )
5246                        .map(PreparedNativeLaunchOverlay::Instance)
5247                        .map_err(|_| failure(
5248                            NodeFailureCode::BundleBindingMismatch,
5249                            "managed session bundle launch overlay is invalid",
5250                        ))
5251                    }).transpose()?;
5252                    return Ok(Some((ownership.id().clone(), overlay)));
5253                }
5254                let environment = match materializer.resolve_environment(&ownership, &profile) {
5255                    Ok(environment) => environment,
5256                    Err(
5257                        SessionEnvironmentMaterializeError::SecretUnavailable
5258                        | SessionEnvironmentMaterializeError::SecretDenied
5259                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5260                    ) => {
5261                        return Err(failure(
5262                            NodeFailureCode::BackendOperationFailed,
5263                            "managed session environment resolution failed",
5264                        ));
5265                    }
5266                    Err(
5267                        SessionEnvironmentMaterializeError::InvalidRoot
5268                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5269                        | SessionEnvironmentMaterializeError::Filesystem(_)
5270                        | SessionEnvironmentMaterializeError::Record(_),
5271                    ) => {
5272                        self.mark_materialization_recovery_required(ownership.id());
5273                        return Err(failure(
5274                            NodeFailureCode::BackendOperationFailed,
5275                            "managed session environment resolution failed",
5276                        ));
5277                    }
5278                };
5279                let transport = match mode {
5280                    SessionMode::Pty => TransportKind::Pty,
5281                    SessionMode::Inline => TransportKind::Pipe,
5282                    SessionMode::Acp => TransportKind::Acp,
5283                };
5284                let overlay = match bundle_arguments {
5285                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5286                        NativeInstanceLaunchOverlay::new(
5287                            provider.clone(),
5288                            transport,
5289                            environment,
5290                            extra_args,
5291                        )
5292                        .map_err(|_| failure(
5293                            NodeFailureCode::BundleBindingMismatch,
5294                            "managed session bundle launch overlay is invalid",
5295                        ))?,
5296                    ),
5297                    None => PreparedNativeLaunchOverlay::Environment(
5298                        NativeLaunchEnvironmentOverlay::new(
5299                            provider.clone(),
5300                            transport,
5301                            environment,
5302                        )
5303                        .map_err(|_| {
5304                            failure(
5305                                NodeFailureCode::BackendOperationFailed,
5306                                "managed session environment overlay is invalid",
5307                            )
5308                        })?,
5309                    ),
5310                };
5311                Ok(Some((ownership.id().clone(), Some(overlay))))
5312            }
5313        }
5314    }
5315
5316    fn destroy_record_materialization(
5317        &self,
5318        record_id: &SessionRecordId,
5319    ) -> Result<bool, NodeFailure> {
5320        let ids = self
5321            .materializations
5322            .lock()
5323            .unwrap_or_else(|poisoned| poisoned.into_inner())
5324            .values()
5325            .filter_map(|record| {
5326                (record.owner()
5327                    == &MaterializationOwner::Record {
5328                        record_id: record_id.clone(),
5329                    })
5330                .then(|| record.id().clone())
5331            })
5332            .collect::<Vec<_>>();
5333        if ids.len() > 1 {
5334            return Err(failure(
5335                NodeFailureCode::BackendOperationFailed,
5336                "managed session has conflicting environment ownership",
5337            ));
5338        }
5339        let Some(id) = ids.first().cloned() else {
5340            return Ok(false);
5341        };
5342        let (terminal_record, mut cleanup_ownership) = {
5343            let _transaction = self
5344                .state_transaction
5345                .lock()
5346                .unwrap_or_else(|poisoned| poisoned.into_inner());
5347            let original_record = self.record(record_id)?;
5348            let mut terminal_record = original_record.clone();
5349            terminal_record.provider_session = None;
5350            terminal_record.active_session = None;
5351            terminal_record.state = ManagedSessionState::Unavailable;
5352            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5353            terminal_record.updated_at_unix_ms = unix_time_ms();
5354            let original_ownership = self
5355                .materializations
5356                .lock()
5357                .unwrap_or_else(|poisoned| poisoned.into_inner())
5358                .get(&id)
5359                .cloned()
5360                .ok_or_else(|| {
5361                    failure(
5362                        NodeFailureCode::BackendOperationFailed,
5363                        "managed session environment ownership disappeared during cleanup",
5364                    )
5365                })?;
5366            let mut cleanup_ownership = original_ownership.clone();
5367            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5368                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5369                    failure(
5370                        NodeFailureCode::BackendOperationFailed,
5371                        "managed session environment cleanup state is invalid",
5372                    )
5373                })?;
5374            }
5375            self.session_records
5376                .lock()
5377                .unwrap_or_else(|poisoned| poisoned.into_inner())
5378                .records
5379                .insert(record_id.clone(), terminal_record.clone());
5380            self.materializations
5381                .lock()
5382                .unwrap_or_else(|poisoned| poisoned.into_inner())
5383                .insert(id.clone(), cleanup_ownership.clone());
5384            if let Err(error) = self.persist_state_locked() {
5385                self.session_records
5386                    .lock()
5387                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5388                    .records
5389                    .insert(record_id.clone(), original_record);
5390                self.materializations
5391                    .lock()
5392                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5393                    .insert(id.clone(), original_ownership);
5394                return Err(persistence_failure(error));
5395            }
5396            (terminal_record, cleanup_ownership)
5397        };
5398        let cleanup = self
5399            .session_environment_materializer
5400            .as_ref()
5401            .ok_or(())
5402            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5403        if cleanup.is_err() {
5404            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5405            self.materializations
5406                .lock()
5407                .unwrap_or_else(|poisoned| poisoned.into_inner())
5408                .insert(id.clone(), cleanup_ownership);
5409            if self.persist_state().is_err() {
5410                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5411            }
5412            return Err(failure(
5413                NodeFailureCode::BackendOperationFailed,
5414                "managed session environment cleanup requires recovery",
5415            ));
5416        }
5417        let _transaction = self
5418            .state_transaction
5419            .lock()
5420            .unwrap_or_else(|poisoned| poisoned.into_inner());
5421        self.remove_record_memory(record_id);
5422        self.materializations
5423            .lock()
5424            .unwrap_or_else(|poisoned| poisoned.into_inner())
5425            .remove(&id);
5426        if let Err(error) = self.persist_state_locked() {
5427            self.session_records
5428                .lock()
5429                .unwrap_or_else(|poisoned| poisoned.into_inner())
5430                .records
5431                .insert(record_id.clone(), terminal_record);
5432            self.materializations
5433                .lock()
5434                .unwrap_or_else(|poisoned| poisoned.into_inner())
5435                .insert(id, cleanup_ownership);
5436            return Err(persistence_failure(error));
5437        }
5438        Ok(true)
5439    }
5440
5441    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5442        let changed = {
5443            let mut materializations = self
5444                .materializations
5445                .lock()
5446                .unwrap_or_else(|poisoned| poisoned.into_inner());
5447            let Some(ownership) = materializations.get_mut(id) else {
5448                return;
5449            };
5450            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5451        };
5452        if changed && self.persist_state().is_err() {
5453            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5454        }
5455    }
5456
5457    fn reconcile_materializations(&self) {
5458        let records = self
5459            .materializations
5460            .lock()
5461            .unwrap_or_else(|poisoned| poisoned.into_inner())
5462            .values()
5463            .cloned()
5464            .collect::<Vec<_>>();
5465        for mut ownership in records {
5466            let result = match (ownership.state(), ownership.owner()) {
5467                (MaterializationState::Preparing, _)
5468                | (MaterializationState::CleanupRequired, _) => {
5469                    self.cleanup_materialization(ownership.id())
5470                }
5471                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5472                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5473                        self.materializations
5474                            .lock()
5475                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5476                            .insert(ownership.id().clone(), ownership.clone());
5477                        self.persist_state().map_err(persistence_failure)
5478                    } else {
5479                        Err(failure(
5480                            NodeFailureCode::BackendOperationFailed,
5481                            "session environment reconciliation failed",
5482                        ))
5483                    }
5484                }
5485                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5486                    .session_environment_materializer
5487                    .as_ref()
5488                    .ok_or_else(|| {
5489                        failure(
5490                            NodeFailureCode::BackendOperationFailed,
5491                            "session environment materializer is unavailable",
5492                        )
5493                    })
5494                    .and_then(|materializer| {
5495                        materializer.revalidate(&ownership).map_err(|_| failure(
5496                            NodeFailureCode::BackendOperationFailed,
5497                            "session materialization revalidation failed",
5498                        ))?;
5499                        let record = self.record(record_id)?;
5500                        if record.environment_profile.as_ref() != ownership.environment_profile()
5501                            || record.bundle.as_ref() != ownership.bundle()
5502                            || record.context.as_ref() != ownership.context()
5503                        {
5504                            return Err(failure(
5505                                NodeFailureCode::BackendOperationFailed,
5506                                "session materialization ownership changed after restart",
5507                            ));
5508                        }
5509                        if let Some(receipt) = ownership.bundle() {
5510                            let catalog = self.bundle_catalog.read()
5511                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5512                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5513                                NodeFailureCode::UnknownBundle,
5514                                "session bundle is unavailable after restart",
5515                            ))?;
5516                            if bundle.receipt() != *receipt {
5517                                return Err(failure(
5518                                    NodeFailureCode::BundleBindingMismatch,
5519                                    "session bundle changed after restart",
5520                                ));
5521                            }
5522                            let layout = self.bundle_layout(
5523                                &record.provider,
5524                                record.mode,
5525                                record.environment_profile.as_ref(),
5526                                bundle,
5527                            )?;
5528                            materializer
5529                                .revalidate_bundle(&ownership, bundle, layout)
5530                                .map_err(|_| failure(
5531                                    NodeFailureCode::BundleMaterializationFailed,
5532                                    "session bundle revalidation failed",
5533                                ))?;
5534                        }
5535                        if let Some(receipt) = ownership.context() {
5536                            let pack = materializer
5537                                .revalidate_context(&ownership, receipt)
5538                                .map_err(|_| {
5539                                    failure(
5540                                        NodeFailureCode::ContextPackMaterializationFailed,
5541                                        "session context revalidation failed",
5542                                    )
5543                                })?;
5544                            self.context_catalog
5545                                .write()
5546                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5547                                .insert(pack)
5548                                .map_err(|_| {
5549                                    failure(
5550                                        NodeFailureCode::ContextPackMaterializationFailed,
5551                                        "session context catalog restore failed",
5552                                    )
5553                                })?;
5554                        }
5555                        Ok(())
5556                    }),
5557                (MaterializationState::RecoveryRequired, _) => Ok(()),
5558            };
5559            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5560                let _ = ownership.mark_recovery_required(unix_time_ms());
5561                self.materializations
5562                    .lock()
5563                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5564                    .insert(ownership.id().clone(), ownership);
5565                if self.persist_state().is_err() {
5566                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5567                }
5568            }
5569        }
5570        let materialized_profiles = self
5571            .environment_materialization_profiles
5572            .read()
5573            .unwrap_or_else(|poisoned| poisoned.into_inner())
5574            .keys()
5575            .cloned()
5576            .collect::<Vec<_>>();
5577        let ownership = self
5578            .materializations
5579            .lock()
5580            .unwrap_or_else(|poisoned| poisoned.into_inner())
5581            .values()
5582            .cloned()
5583            .collect::<Vec<_>>();
5584        let changed = {
5585            let mut session_records = self
5586                .session_records
5587                .lock()
5588                .unwrap_or_else(|poisoned| poisoned.into_inner());
5589            let mut changed = false;
5590            for record in session_records.records.values_mut() {
5591                let environment_profile = record.environment_profile.as_ref();
5592                if environment_profile.is_none()
5593                    && record.bundle.is_none()
5594                    && record.context.is_none()
5595                {
5596                    continue;
5597                }
5598                if record.bundle.is_none()
5599                    && environment_profile.is_some_and(|profile| {
5600                        !materialized_profiles.contains(&profile.profile_id)
5601                    })
5602                {
5603                    continue;
5604                }
5605                let has_exact_ownership = ownership.iter().any(|candidate| {
5606                    candidate.state() == MaterializationState::Ready
5607                        && candidate.environment_profile() == environment_profile
5608                        && candidate.bundle() == record.bundle.as_ref()
5609                        && candidate.context() == record.context.as_ref()
5610                        && candidate.owner()
5611                            == &MaterializationOwner::Record {
5612                                record_id: record.record_id.clone(),
5613                            }
5614                });
5615                if !has_exact_ownership {
5616                    record.provider_session = None;
5617                    record.active_session = None;
5618                    record.state = ManagedSessionState::Unavailable;
5619                    record.last_error = Some(if record.bundle.is_some() {
5620                        "bundle-unavailable".to_owned()
5621                    } else if record.context.is_some() {
5622                        "context-unavailable".to_owned()
5623                    } else {
5624                        "environment-profile-unavailable".to_owned()
5625                    });
5626                    record.updated_at_unix_ms = unix_time_ms();
5627                    changed = true;
5628                }
5629            }
5630            changed
5631        };
5632        if changed && self.persist_state().is_err() {
5633            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5634        }
5635    }
5636
5637    fn replay_spawn_spec(
5638        &self,
5639        spec: &SpawnSpec,
5640        now: Instant,
5641    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5642        let mut cache = self
5643            .spawn_idempotency
5644            .lock()
5645            .unwrap_or_else(|poisoned| poisoned.into_inner());
5646        cache.entries.retain(|_, entry| entry.expires_at > now);
5647        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5648            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5649                return Err(failure(
5650                    NodeFailureCode::SpawnIdempotencyConflict,
5651                    "spawn idempotency key was reused across spawn kinds",
5652                ));
5653            };
5654            if existing != spec {
5655                return Err(failure(
5656                    NodeFailureCode::SpawnIdempotencyConflict,
5657                    "spawn idempotency key was reused with a different specification",
5658                ));
5659            }
5660            return Ok(Some(result.clone()));
5661        }
5662        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5663            return Err(failure(
5664                NodeFailureCode::SpawnIdempotencyCapacity,
5665                "spawn idempotency cache reached its bounded capacity",
5666            ));
5667        }
5668        Ok(None)
5669    }
5670
5671    fn remember_spawn_spec(
5672        &self,
5673        spec: SpawnSpec,
5674        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5675        accepted_at: Instant,
5676    ) {
5677        self
5678            .spawn_idempotency
5679            .lock()
5680            .unwrap_or_else(|poisoned| poisoned.into_inner())
5681            .entries
5682            .insert(
5683                spec.idempotency_key.clone(),
5684                SpawnIdempotencyEntry {
5685                    value: SpawnIdempotencyValue::Standard { spec, result },
5686                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5687                },
5688            );
5689    }
5690
5691    fn claim_harness_mcp_spawn(
5692        &self,
5693        _reservation_id: &HarnessMcpReservationId,
5694        _activation_digest: &HarnessMcpActivationDigest,
5695        spec: &SpawnSpec,
5696        now: Instant,
5697    ) -> Result<(), NodeFailure> {
5698        let mut cache = self.spawn_idempotency.lock()
5699            .unwrap_or_else(|poisoned| poisoned.into_inner());
5700        cache.entries.retain(|_, entry| entry.expires_at > now);
5701        if cache.entries.contains_key(&spec.idempotency_key) {
5702            return Err(failure(
5703                NodeFailureCode::SpawnIdempotencyConflict,
5704                "spawn idempotency key was reused across spawn kinds",
5705            ));
5706        }
5707        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5708            return Err(failure(
5709                NodeFailureCode::SpawnIdempotencyCapacity,
5710                "spawn idempotency cache reached its bounded capacity",
5711            ));
5712        }
5713        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5714            value: SpawnIdempotencyValue::HarnessMcp,
5715            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5716        });
5717        Ok(())
5718    }
5719
5720    fn replay_managed_spawn(
5721        &self,
5722        request: &ManagedWorktreeSpawnRequest,
5723        now: Instant,
5724    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5725        let mut cache = self.spawn_idempotency.lock()
5726            .unwrap_or_else(|poisoned| poisoned.into_inner());
5727        cache.entries.retain(|_, entry| entry.expires_at > now);
5728        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5729            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5730                return Err(failure(
5731                    NodeFailureCode::SpawnIdempotencyConflict,
5732                    "spawn idempotency key was reused across spawn kinds",
5733                ));
5734            };
5735            if existing != request {
5736                return Err(failure(
5737                    NodeFailureCode::SpawnIdempotencyConflict,
5738                    "spawn idempotency key was reused with a different managed specification",
5739                ));
5740            }
5741            return Ok(Some(result.clone()));
5742        }
5743        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5744            return Err(failure(
5745                NodeFailureCode::SpawnIdempotencyCapacity,
5746                "spawn idempotency cache reached its bounded capacity",
5747            ));
5748        }
5749        Ok(None)
5750    }
5751
5752    fn remember_managed_spawn(
5753        &self,
5754        request: ManagedWorktreeSpawnRequest,
5755        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5756        accepted_at: Instant,
5757    ) {
5758        self.spawn_idempotency.lock()
5759            .unwrap_or_else(|poisoned| poisoned.into_inner())
5760            .entries
5761            .insert(
5762                request.spawn_spec.idempotency_key.clone(),
5763                SpawnIdempotencyEntry {
5764                    value: SpawnIdempotencyValue::Managed { request, result },
5765                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5766                },
5767            );
5768    }
5769
5770    fn remember_managed_spawn_attempt(
5771        &self,
5772        durable_v2_key: Option<&SpawnIdempotencyKey>,
5773        request: ManagedWorktreeSpawnRequest,
5774        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5775        accepted_at: Instant,
5776    ) {
5777        if durable_v2_key.is_none() {
5778            self.remember_managed_spawn(request, result, accepted_at);
5779        }
5780    }
5781
5782    fn acquire_durable_managed_spawn_v2(
5783        &self,
5784        request: &ManagedWorktreeSpawnRequestV2,
5785        request_digest: String,
5786    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5787        if self.state_path.is_none() {
5788            return Err(failure(
5789                NodeFailureCode::BackendOperationFailed,
5790                "managed worktree V2 requires durable node state",
5791            ));
5792        }
5793        let _transaction = self.state_transaction
5794            .lock()
5795            .unwrap_or_else(|poisoned| poisoned.into_inner());
5796        let key = request.spawn_spec.idempotency_key.clone();
5797        let mut previous = None;
5798        {
5799            let mut replays = self.managed_spawn_replays
5800                .lock()
5801                .unwrap_or_else(|poisoned| poisoned.into_inner());
5802            if let Some(record) = replays.get_mut(&key) {
5803                if record.request_digest != request_digest {
5804                    return Err(failure(
5805                        NodeFailureCode::SpawnIdempotencyConflict,
5806                        "managed worktree V2 idempotency key was reused with a different request",
5807                    ));
5808                }
5809                match &record.state {
5810                    ManagedWorktreeSpawnReplayStateV10::Pending {
5811                        owner_incarnation_id,
5812                        lease_id: None,
5813                    } if owner_incarnation_id != &self.incarnation_id => {
5814                        previous = Some(record.clone());
5815                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5816                            owner_incarnation_id: self.incarnation_id,
5817                            lease_id: None,
5818                        };
5819                        record.updated_at_unix_ms = unix_time_ms();
5820                    }
5821                    ManagedWorktreeSpawnReplayStateV10::Pending {
5822                        lease_id: None,
5823                        ..
5824                    } => {
5825                        return Err(failure(
5826                            NodeFailureCode::BackendBusy,
5827                            "managed worktree V2 reservation is already active",
5828                        ));
5829                    }
5830                    ManagedWorktreeSpawnReplayStateV10::Pending {
5831                        lease_id: Some(_),
5832                        ..
5833                    } => {
5834                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5835                    }
5836                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5837                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5838                            receipt.clone(),
5839                        ));
5840                    }
5841                }
5842            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5843                return Err(failure(
5844                    NodeFailureCode::SpawnIdempotencyCapacity,
5845                    "durable managed worktree replay ledger reached its bounded capacity",
5846                ));
5847            } else {
5848                let now = unix_time_ms();
5849                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5850                    idempotency_key: key.clone(),
5851                    request_digest,
5852                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5853                    profile_id: request.worktree_profile_id.clone(),
5854                    expected_profile_revision: request.expected_profile_revision.clone(),
5855                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5856                        owner_incarnation_id: self.incarnation_id,
5857                        lease_id: None,
5858                    },
5859                    created_at_unix_ms: now,
5860                    updated_at_unix_ms: now,
5861                });
5862            }
5863        }
5864        if let Err(error) = self.persist_state_locked() {
5865            let mut replays = self.managed_spawn_replays
5866                .lock()
5867                .unwrap_or_else(|poisoned| poisoned.into_inner());
5868            match previous {
5869                Some(previous) => {
5870                    replays.insert(key, previous);
5871                }
5872                None => {
5873                    replays.remove(&key);
5874                }
5875            }
5876            return Err(persistence_failure(error));
5877        }
5878        Ok(DurableManagedSpawnReplayDecision::Reserved)
5879    }
5880
5881    fn link_durable_managed_spawn_v2_lease_locked(
5882        &self,
5883        key: &SpawnIdempotencyKey,
5884        lease_id: &ManagedWorktreeLeaseId,
5885    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5886        let mut replays = self.managed_spawn_replays
5887            .lock()
5888            .unwrap_or_else(|poisoned| poisoned.into_inner());
5889        let record = replays.get_mut(key).ok_or_else(|| {
5890            failure(
5891                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5892                "managed worktree V2 reservation disappeared before allocation",
5893            )
5894        })?;
5895        let previous = record.clone();
5896        match &mut record.state {
5897            ManagedWorktreeSpawnReplayStateV10::Pending {
5898                lease_id: current,
5899                ..
5900            }
5901                if current.is_none() =>
5902            {
5903                *current = Some(lease_id.clone());
5904                record.updated_at_unix_ms = unix_time_ms();
5905                Ok(previous)
5906            }
5907            _ => Err(failure(
5908                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5909                "managed worktree V2 reservation is not allocation-ready",
5910            )),
5911        }
5912    }
5913
5914    fn restore_durable_managed_spawn_v2_locked(
5915        &self,
5916        key: &SpawnIdempotencyKey,
5917        previous: ManagedWorktreeSpawnReplayRecordV10,
5918    ) {
5919        self.managed_spawn_replays
5920            .lock()
5921            .unwrap_or_else(|poisoned| poisoned.into_inner())
5922            .insert(key.clone(), previous);
5923    }
5924
5925    fn commit_durable_managed_spawn_v2(
5926        &self,
5927        request: &ManagedWorktreeSpawnRequestV2,
5928        receipt: ManagedWorktreeSpawnReceipt,
5929    ) -> Result<(), NodeFailure> {
5930        let _transaction = self.state_transaction
5931            .lock()
5932            .unwrap_or_else(|poisoned| poisoned.into_inner());
5933        let key = &request.spawn_spec.idempotency_key;
5934        let previous = {
5935            let mut replays = self.managed_spawn_replays
5936                .lock()
5937                .unwrap_or_else(|poisoned| poisoned.into_inner());
5938            let record = replays.get_mut(key).ok_or_else(|| {
5939                failure(
5940                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
5941                    "managed worktree V2 reservation disappeared before commit",
5942                )
5943            })?;
5944            let previous = record.clone();
5945            match &record.state {
5946                ManagedWorktreeSpawnReplayStateV10::Pending {
5947                    lease_id: Some(lease_id),
5948                    ..
5949                } if lease_id == &receipt.lease.lease_id => {}
5950                _ => {
5951                    return Err(failure(
5952                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
5953                        "managed worktree V2 reservation is not linked to the spawned lease",
5954                    ));
5955                }
5956            }
5957            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5958            record.updated_at_unix_ms = unix_time_ms();
5959            previous
5960        };
5961        if let Err(error) = self.persist_state_locked() {
5962            self.restore_durable_managed_spawn_v2_locked(key, previous);
5963            return Err(persistence_failure(error));
5964        }
5965        Ok(())
5966    }
5967
5968    fn remove_unallocated_durable_managed_spawn_v2(
5969        &self,
5970        request: &ManagedWorktreeSpawnRequestV2,
5971    ) -> Result<(), NodeFailure> {
5972        let _transaction = self.state_transaction
5973            .lock()
5974            .unwrap_or_else(|poisoned| poisoned.into_inner());
5975        let key = &request.spawn_spec.idempotency_key;
5976        let removed = {
5977            let mut replays = self.managed_spawn_replays
5978                .lock()
5979                .unwrap_or_else(|poisoned| poisoned.into_inner());
5980            match replays.get(key).map(|record| &record.state) {
5981                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5982                    lease_id: None,
5983                    ..
5984                }) => {
5985                    replays.remove(key)
5986                }
5987                _ => None,
5988            }
5989        };
5990        let Some(removed) = removed else {
5991            return Ok(());
5992        };
5993        if let Err(error) = self.persist_state_locked() {
5994            self.managed_spawn_replays
5995                .lock()
5996                .unwrap_or_else(|poisoned| poisoned.into_inner())
5997                .insert(key.clone(), removed);
5998            return Err(persistence_failure(error));
5999        }
6000        Ok(())
6001    }
6002
6003    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6004        if spec.target.node_id != self.node_id {
6005            return Err(failure(
6006                NodeFailureCode::SpawnTargetMismatch,
6007                "spawn target does not match this node",
6008            ));
6009        }
6010        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6011            failure(
6012                NodeFailureCode::UnknownSpawnProfile,
6013                "spawn profile is unavailable on this node",
6014            )
6015        })?;
6016        let resolved = spec.resolve(defaults).map_err(|error| match error {
6017            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6018                NodeFailureCode::SpawnProfileRevisionMismatch,
6019                "spawn profile revision does not match the loaded profile",
6020            ),
6021            _ => failure(
6022                NodeFailureCode::InvalidRequest,
6023                "spawn specification could not be resolved",
6024            ),
6025        })?;
6026        if resolved.target.worktree_id.is_some() {
6027            self.require_worktree_service(&resolved.target.workspace_id)?;
6028        }
6029        let environment_profile = self.resolve_environment_profile(&resolved)?;
6030        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6031        self.resolve_context(&resolved)?;
6032        Ok(resolved)
6033    }
6034
6035    async fn resolve_spawn_workspace(
6036        &self,
6037        resolved: &ResolvedSpawnSpec,
6038        deadline: Instant,
6039    ) -> Result<WorkspaceId, NodeFailure> {
6040        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6041            return Ok(resolved.target.workspace_id.clone());
6042        };
6043        if self.managed_worktrees.lock()
6044            .unwrap_or_else(|poisoned| poisoned.into_inner())
6045            .lease_for_workspace(worktree_id)
6046            .is_some()
6047        {
6048            return Err(failure(
6049                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6050                "managed worktree targets may only be spawned through their lease request",
6051            ));
6052        }
6053        self.require_worktree_service(&resolved.target.workspace_id)?;
6054        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6055        let selected_root = self.workspace_root(worktree_id)?;
6056        let remaining = spawn_deadline_remaining(deadline)?;
6057        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6058            .await
6059            .map_err(|_| {
6060                failure(
6061                    NodeFailureCode::SpawnDeadlineExceeded,
6062                    "Git worktree selection exceeded the spawn deadline",
6063                )
6064            })?
6065            .map_err(git_worktree_failure)?;
6066        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6067        Ok(worktree_id.clone())
6068    }
6069
6070    async fn spawn_from_spec(
6071        &self,
6072        spec: SpawnSpec,
6073    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6074        self.spawn_from_spec_with_proxy(spec, None).await
6075    }
6076
6077    async fn spawn_from_spec_with_proxy(
6078        &self,
6079        spec: SpawnSpec,
6080        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6081    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6082        let accepted_at = Instant::now();
6083        if harness_mcp.is_none() {
6084            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6085                return replayed;
6086            }
6087        }
6088        let resolved = self.resolve_spawn_spec(&spec)?;
6089        let environment_profile = self.resolve_environment_profile(&resolved)?;
6090        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6091        let context = self.resolve_context(&resolved)?;
6092        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6093        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6094        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6095        let spawn_workspace_id = match spawn_workspace_id {
6096            Ok(workspace_id) => workspace_id,
6097            Err(error) => {
6098                let result = Err(error);
6099                if harness_mcp.is_none() {
6100                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6101                }
6102                return result;
6103            }
6104        };
6105        if self.managed_worktrees.lock()
6106            .unwrap_or_else(|poisoned| poisoned.into_inner())
6107            .lease_for_workspace(&spawn_workspace_id)
6108            .is_some()
6109        {
6110            let result = Err(failure(
6111                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6112                "managed worktree targets may only be spawned through their lease request",
6113            ));
6114            if harness_mcp.is_none() {
6115                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6116            }
6117            return result;
6118        }
6119        let result = self
6120            .spawn_session_with_deadline(
6121                spawn_workspace_id,
6122                resolved.provider.clone(),
6123                resolved.mode,
6124                resolved.terminal_size,
6125                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6126                environment_profile.clone(),
6127                bundle.clone(),
6128                context.clone(),
6129                None,
6130                None,
6131                SpawnRecordPolicy::Always,
6132                Some(deadline),
6133                &required_capabilities,
6134                harness_mcp,
6135                resolved.approval_level,
6136                resolved.network_allowlist.clone(),
6137                resolved.browser_profile_id.clone(),
6138            )
6139            .await
6140            .map(|(session, _runtime_policy)| {
6141                resolved.receipt_with_materialization(
6142                    self.incarnation_id,
6143                    session,
6144                    environment_profile,
6145                    bundle,
6146                    context,
6147                )
6148            });
6149        if harness_mcp.is_none() {
6150            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6151        }
6152        result
6153    }
6154
6155    async fn spawn_from_spec_with_harness_mcp(
6156        &self,
6157        reservation_id: HarnessMcpReservationId,
6158        activation_digest: HarnessMcpActivationDigest,
6159        spec: SpawnSpec,
6160        deadline_unix_ms: u64,
6161    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6162        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6163            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6164        })?;
6165        if let Some(receipt) = registry
6166            .replay_spawn(&reservation_id, &activation_digest, &spec)
6167            .map_err(harness_mcp_failure)?
6168        {
6169            return Ok(receipt);
6170        }
6171        let resolved = self.resolve_spawn_spec(&spec)?;
6172        let prepared = registry
6173            .prepare_spawn(
6174                &reservation_id,
6175                &activation_digest,
6176                &spec,
6177                deadline_unix_ms,
6178                resolved.provider.clone(),
6179            )
6180            .map_err(harness_mcp_failure)?;
6181        self.claim_harness_mcp_spawn(
6182            &reservation_id,
6183            &activation_digest,
6184            &spec,
6185            Instant::now(),
6186        )?;
6187        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6188        let mut receipt = match spawn {
6189            Ok(receipt) => receipt,
6190            Err(error) => {
6191                let _ = registry.abort(&reservation_id, &activation_digest);
6192                return Err(error);
6193            }
6194        };
6195        let record_id = self
6196            .session_bindings
6197            .lock()
6198            .unwrap_or_else(|poisoned| poisoned.into_inner())
6199            .get(&receipt.session.session.instance_id)
6200            .and_then(|binding| binding.record_id.clone());
6201        let record_id = match record_id {
6202            Some(record_id) => record_id,
6203            None => {
6204                let rollback = self.remove_session(&receipt.session).await;
6205                let _ = registry.abort(&reservation_id, &activation_digest);
6206                if let Err(rollback_error) = rollback {
6207                    return Err(rollback_error);
6208                }
6209                return Err(failure(
6210                    NodeFailureCode::BindingMismatch,
6211                    "harness MCP spawn did not retain its exact managed record",
6212                ));
6213            }
6214        };
6215        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6216            reservation_id: reservation_id.clone(),
6217            activation_digest: activation_digest.clone(),
6218        });
6219        if let Err(error) = registry.mark_spawned(
6220            &prepared,
6221            receipt.session.clone(),
6222            record_id,
6223            receipt.clone(),
6224        ) {
6225            let rollback = self.remove_session(&receipt.session).await;
6226            let _ = registry.abort(&reservation_id, &activation_digest);
6227            if let Err(rollback_error) = rollback {
6228                return Err(rollback_error);
6229            }
6230            return Err(harness_mcp_failure(error));
6231        }
6232        Ok(receipt)
6233    }
6234
6235    async fn spawn_managed_worktree(
6236        &self,
6237        request: ManagedWorktreeSpawnRequest,
6238    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6239        self.spawn_managed_worktree_inner(request, None).await
6240    }
6241
6242    async fn spawn_managed_worktree_inner(
6243        &self,
6244        request: ManagedWorktreeSpawnRequest,
6245        durable_v2_key: Option<&SpawnIdempotencyKey>,
6246    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6247        let accepted_at = Instant::now();
6248        if durable_v2_key.is_none() {
6249            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6250                return replayed;
6251            }
6252        }
6253        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6254        if request.spawn_spec.target.worktree_id.is_some() {
6255            let result = Err(failure(
6256                NodeFailureCode::InvalidRequest,
6257                "managed worktree spawn must not select a caller-provided worktree",
6258            ));
6259            self.remember_managed_spawn_attempt(
6260                durable_v2_key,
6261                request,
6262                result.clone(),
6263                accepted_at,
6264            );
6265            return result;
6266        }
6267        let environment_profile = self.resolve_environment_profile(&resolved)?;
6268        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6269        let context = self.resolve_context(&resolved)?;
6270        let source_workspace_id = resolved.target.workspace_id.clone();
6271        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6272        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6273        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6274        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6275            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6276            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6277            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6278            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6279        };
6280        let runtime_policy = timeout(
6281            spawn_deadline_remaining(deadline)?,
6282            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6283        ).await.map_err(|_| failure(
6284            NodeFailureCode::SpawnDeadlineExceeded,
6285            "provider admission exceeded the managed spawn deadline",
6286        ))??;
6287        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6288            return Err(failure(
6289                NodeFailureCode::UnsupportedSpawnCapability,
6290                "provider runtime does not admit a required spawn capability",
6291            ));
6292        }
6293        self.ensure_binding_capacity()?;
6294        let source_root = self.workspace_root(&source_workspace_id)?;
6295        profile.validate_for_workspace(&source_root)
6296            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6297        let base_timeout_ms = u64::try_from(
6298            spawn_deadline_remaining(deadline)?.as_millis(),
6299        ).unwrap_or(u64::MAX).max(1);
6300        let base_commit = resolve_base_commit_with_timeout(
6301            &source_root,
6302            profile.base(),
6303            base_timeout_ms,
6304        ).await.map_err(|error| {
6305            if Instant::now() >= deadline || error.message.contains("timed out") {
6306                failure(
6307                    NodeFailureCode::SpawnDeadlineExceeded,
6308                    "managed worktree base resolution exceeded the spawn deadline",
6309                )
6310            } else {
6311                managed_git_worktree_failure(error)
6312            }
6313        })?;
6314
6315        let lease = {
6316            let _transaction = self.state_transaction.lock()
6317                .unwrap_or_else(|poisoned| poisoned.into_inner());
6318            let previous = self.managed_worktrees.lock()
6319                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6320            let lease = self.managed_worktrees.lock()
6321                .unwrap_or_else(|poisoned| poisoned.into_inner())
6322                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6323                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6324            let previous_replay = match durable_v2_key
6325                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6326                .transpose()
6327            {
6328                Ok(previous) => previous,
6329                Err(error) => {
6330                    *self.managed_worktrees.lock()
6331                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6332                    return Err(error);
6333                }
6334            };
6335            if let Err(error) = self.persist_state_locked() {
6336                *self.managed_worktrees.lock()
6337                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6338                if let (Some(key), Some(previous_replay)) =
6339                    (durable_v2_key, previous_replay)
6340                {
6341                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6342                }
6343                return Err(persistence_failure(error));
6344            }
6345            lease
6346        };
6347        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6348        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6349            self.managed_worktrees.lock()
6350                .unwrap_or_else(|poisoned| poisoned.into_inner())
6351                .remove_unmutated(&lease.lease_id);
6352            if self.persist_state().is_err() {
6353                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6354            }
6355            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6356            self.remember_managed_spawn_attempt(
6357                durable_v2_key,
6358                request,
6359                result.clone(),
6360                accepted_at,
6361            );
6362            return result;
6363        }
6364        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6365            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6366            Err(error) => {
6367                self.reconcile_failed_allocation(&lease, &source_root).await;
6368                let result = Err(error);
6369                self.remember_managed_spawn_attempt(
6370                    durable_v2_key,
6371                    request,
6372                    result.clone(),
6373                    accepted_at,
6374                );
6375                return result;
6376            }
6377        };
6378        let created = match create_git_worktree_with_timeout(
6379                &source_root,
6380                &lease.target_root,
6381                &lease.branch,
6382                Some(&base_commit),
6383                mutation_timeout_ms,
6384            ).await {
6385            Ok(created) => created,
6386            Err(error) => {
6387                self.reconcile_failed_allocation(&lease, &source_root).await;
6388                let result = Err(if Instant::now() >= deadline
6389                    || error.message.contains("timed out")
6390                    || error.message.contains("deadline")
6391                {
6392                    failure(
6393                        NodeFailureCode::SpawnDeadlineExceeded,
6394                        "managed worktree creation exceeded the spawn deadline",
6395                    )
6396                } else {
6397                    managed_git_worktree_failure(error)
6398                });
6399                self.remember_managed_spawn_attempt(
6400                    durable_v2_key,
6401                    request,
6402                    result.clone(),
6403                    accepted_at,
6404                );
6405                return result;
6406            }
6407        };
6408        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6409            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6410            let result = Err(failure(
6411                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6412                "created worktree identity did not match its durable lease",
6413            ));
6414            self.remember_managed_spawn_attempt(
6415                durable_v2_key,
6416                request,
6417                result.clone(),
6418                accepted_at,
6419            );
6420            return result;
6421        }
6422        {
6423            let mut registry = self.managed_worktrees.lock()
6424                .unwrap_or_else(|poisoned| poisoned.into_inner());
6425            let current = registry.get_mut(&lease.lease_id)
6426                .expect("allocated managed worktree lease remains present");
6427            current.expected_head = Some(created.head.clone());
6428            current.state = ManagedWorktreeLeaseState::Ready;
6429            current.updated_at_unix_ms = unix_time_ms();
6430        }
6431        if let Err(error) = self.persist_state() {
6432            self.mark_managed_recovery_required(
6433                &lease.lease_id,
6434                ManagedWorktreeCleanupFailure::Backend,
6435            );
6436            let result = Err(persistence_failure(error));
6437            self.remember_managed_spawn_attempt(
6438                durable_v2_key,
6439                request,
6440                result.clone(),
6441                accepted_at,
6442            );
6443            return result;
6444        }
6445        let ready = self.managed_worktrees.lock()
6446            .unwrap_or_else(|poisoned| poisoned.into_inner())
6447            .get(&lease.lease_id)
6448            .expect("ready managed worktree lease remains present")
6449            .snapshot();
6450        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6451        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6452            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6453            let result = Err(error);
6454            self.remember_managed_spawn_attempt(
6455                durable_v2_key,
6456                request,
6457                result.clone(),
6458                accepted_at,
6459            );
6460            return result;
6461        }
6462        let spawn = self.spawn_session_with_deadline(
6463            lease.workspace_id.clone(),
6464            resolved.provider.clone(),
6465            resolved.mode,
6466            resolved.terminal_size,
6467            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6468            environment_profile.clone(),
6469            bundle.clone(),
6470            context.clone(),
6471            Some(lease.lease_id.clone()),
6472            Some(runtime_policy),
6473            SpawnRecordPolicy::Always,
6474            Some(deadline),
6475            &required_capabilities,
6476            None,
6477            resolved.approval_level,
6478            resolved.network_allowlist.clone(),
6479            resolved.browser_profile_id.clone(),
6480        ).await;
6481        let (session, effective_runtime_policy) = match spawn {
6482            Ok(spawned) => spawned,
6483            Err(error) => {
6484                if self
6485                    .cleanup_managed_worktree(&lease.lease_id, true)
6486                    .await
6487                    .is_err()
6488                {
6489                    self.mark_managed_recovery_required(
6490                        &lease.lease_id,
6491                        ManagedWorktreeCleanupFailure::Backend,
6492                    );
6493                }
6494                let result = Err(error);
6495                self.remember_managed_spawn_attempt(
6496                    durable_v2_key,
6497                    request,
6498                    result.clone(),
6499                    accepted_at,
6500                );
6501                return result;
6502            }
6503        };
6504        let (record_id, raw_inventory_record_id) = {
6505            let mut bindings = self.session_bindings.lock()
6506                .unwrap_or_else(|poisoned| poisoned.into_inner());
6507            let binding = bindings.get_mut(&session.session.instance_id)
6508                .expect("successful spawn retains its session binding");
6509            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6510            managed_spawn_record_ownership(binding, effective_runtime_policy)
6511        };
6512        let bound = self.managed_worktrees.lock()
6513            .unwrap_or_else(|poisoned| poisoned.into_inner())
6514            .bind_session(
6515                &lease.lease_id,
6516                ManagedWorktreeSessionHolder {
6517                    incarnation_id: self.incarnation_id,
6518                    instance_id: session.session.instance_id,
6519                    generation: session.session.generation,
6520                },
6521                record_id,
6522                unix_time_ms(),
6523            )
6524            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6525        let snapshot = match bound {
6526            Ok(snapshot) => snapshot,
6527            Err(error) => {
6528                if raw_inventory_record_id
6529                    .as_ref()
6530                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6531                {
6532                    self.mark_managed_recovery_required(
6533                        &lease.lease_id,
6534                        ManagedWorktreeCleanupFailure::Backend,
6535                    );
6536                }
6537                let _ = self.remove_session(&session).await;
6538                self.mark_managed_recovery_required(
6539                    &lease.lease_id,
6540                    ManagedWorktreeCleanupFailure::Backend,
6541                );
6542                let result = Err(error);
6543                self.remember_managed_spawn_attempt(
6544                    durable_v2_key,
6545                    request,
6546                    result.clone(),
6547                    accepted_at,
6548                );
6549                return result;
6550            }
6551        };
6552        if let Err(error) = self.persist_state() {
6553            if raw_inventory_record_id
6554                .as_ref()
6555                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6556            {
6557                self.mark_managed_recovery_required(
6558                    &lease.lease_id,
6559                    ManagedWorktreeCleanupFailure::Backend,
6560                );
6561            }
6562            let _ = self.remove_session(&session).await;
6563            if self
6564                .cleanup_managed_worktree(&lease.lease_id, true)
6565                .await
6566                .is_err()
6567            {
6568                self.mark_managed_recovery_required(
6569                    &lease.lease_id,
6570                    ManagedWorktreeCleanupFailure::Backend,
6571                );
6572            }
6573            let result = Err(persistence_failure(error));
6574            self.remember_managed_spawn_attempt(
6575                durable_v2_key,
6576                request,
6577                result.clone(),
6578                accepted_at,
6579            );
6580            return result;
6581        }
6582        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6583        let receipt = managed_spawn_receipt(
6584            &resolved,
6585            self.incarnation_id,
6586            session,
6587            snapshot,
6588            environment_profile,
6589            bundle,
6590            context,
6591        );
6592        let result = Ok(receipt);
6593        self.remember_managed_spawn_attempt(
6594            durable_v2_key,
6595            request,
6596            result.clone(),
6597            accepted_at,
6598        );
6599        result
6600    }
6601
6602    async fn spawn_managed_worktree_v2(
6603        &self,
6604        request: ManagedWorktreeSpawnRequestV2,
6605    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6606        if request.spawn_spec.target.node_id != self.node_id {
6607            return Err(failure(
6608                NodeFailureCode::SpawnTargetMismatch,
6609                "spawn target does not match this node",
6610            ));
6611        }
6612        if request.spawn_spec.target.worktree_id.is_some() {
6613            return Err(failure(
6614                NodeFailureCode::InvalidRequest,
6615                "managed worktree spawn must not select a caller-provided worktree",
6616            ));
6617        }
6618        let request_digest = managed_spawn_request_digest_v2(&request)?;
6619        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6620        match replay {
6621            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6622            DurableManagedSpawnReplayDecision::PendingLinked => {
6623                return Err(failure(
6624                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6625                    "managed worktree V2 reservation has an incomplete durable allocation",
6626                ));
6627            }
6628            DurableManagedSpawnReplayDecision::Reserved => {}
6629        }
6630        let validated = (|| {
6631            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6632            let profile = self.managed_profile(
6633                &resolved.target.workspace_id,
6634                &request.worktree_profile_id,
6635            )?;
6636            if profile.revision() != &request.expected_profile_revision {
6637                return Err(failure(
6638                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6639                    "managed worktree profile revision does not match the loaded profile",
6640                ));
6641            }
6642            Ok(())
6643        })();
6644        match validated {
6645            Ok(()) => {}
6646            Err(error) => {
6647                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6648                return Err(error);
6649            }
6650        }
6651        let legacy = ManagedWorktreeSpawnRequest {
6652            spawn_spec: request.spawn_spec.clone(),
6653            worktree_profile_id: request.worktree_profile_id.clone(),
6654        };
6655        match self
6656            .spawn_managed_worktree_inner(
6657                legacy,
6658                Some(&request.spawn_spec.idempotency_key),
6659            )
6660            .await
6661        {
6662            Ok(receipt) => {
6663                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6664                Ok(receipt)
6665            }
6666            Err(error) => {
6667                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6668                Err(error)
6669            }
6670        }
6671    }
6672
6673    fn managed_profile(
6674        &self,
6675        workspace_id: &WorkspaceId,
6676        profile_id: &WorktreeProfileId,
6677    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6678        if self.worktree_service_modes.get(workspace_id).copied()
6679            != Some(WorktreeServiceMode::Managed)
6680        {
6681            return Err(failure(
6682                NodeFailureCode::UnsupportedSpawnCapability,
6683                "workspace does not enable managed worktrees",
6684            ));
6685        }
6686        self.managed_worktree_profiles.get(workspace_id)
6687            .and_then(|profiles| profiles.get(profile_id))
6688            .cloned()
6689            .ok_or_else(|| failure(
6690                NodeFailureCode::InvalidRequest,
6691                "managed worktree profile is unavailable for this workspace",
6692            ))
6693    }
6694
6695    async fn reconcile_failed_allocation(
6696        &self,
6697        lease: &ManagedWorktreeLeaseRecord,
6698        source_root: &str,
6699    ) {
6700        match list_git_worktrees(source_root).await {
6701            Ok(worktrees) => {
6702                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6703                    None => {
6704                        match std::fs::symlink_metadata(&lease.target_root) {
6705                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6706                                self.managed_worktrees.lock()
6707                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6708                                    .remove_unmutated(&lease.lease_id);
6709                                let _ = self.persist_state();
6710                            }
6711                            _ => self.mark_managed_recovery_required(
6712                                &lease.lease_id,
6713                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6714                            ),
6715                        }
6716                    }
6717                    Some(item) if exact_owned_worktree(
6718                        lease,
6719                        &item.path,
6720                        item.branch.as_deref(),
6721                        &item.head,
6722                    ) => {
6723                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6724                    }
6725                    Some(_) => self.mark_managed_recovery_required(
6726                        &lease.lease_id,
6727                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6728                    ),
6729                }
6730            }
6731            Err(_) => self.mark_managed_recovery_required(
6732                &lease.lease_id,
6733                ManagedWorktreeCleanupFailure::Backend,
6734            ),
6735        }
6736    }
6737
6738    async fn reconcile_managed_worktrees(&self) {
6739        let leases = self.managed_worktrees.lock()
6740            .unwrap_or_else(|poisoned| poisoned.into_inner())
6741            .active_records()
6742            .cloned()
6743            .collect::<Vec<_>>();
6744        for lease in leases {
6745            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6746                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6747                _ => {
6748                    self.mark_managed_recovery_required(
6749                        &lease.lease_id,
6750                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6751                    );
6752                    continue;
6753                }
6754            };
6755            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6756                self.mark_managed_recovery_required(
6757                    &lease.lease_id,
6758                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6759                );
6760                continue;
6761            }
6762            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6763                Ok(root) => root,
6764                Err(_) => {
6765                    self.mark_managed_recovery_required(
6766                        &lease.lease_id,
6767                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6768                    );
6769                    continue;
6770                }
6771            };
6772            let worktrees = match list_git_worktrees(&source_root).await {
6773                Ok(worktrees) => worktrees,
6774                Err(_) => {
6775                    self.mark_managed_recovery_required(
6776                        &lease.lease_id,
6777                        ManagedWorktreeCleanupFailure::Backend,
6778                    );
6779                    continue;
6780                }
6781            };
6782            let listed = worktrees.iter()
6783                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6784            let Some(listed) = listed else {
6785                match std::fs::symlink_metadata(&lease.target_root) {
6786                    Err(error) if error.kind() == io::ErrorKind::NotFound
6787                        && lease.state == ManagedWorktreeLeaseState::Allocating
6788                        && !lease.has_holders() => {
6789                            let removed = self.managed_worktrees.lock()
6790                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6791                                .tombstone(&lease.lease_id, unix_time_ms());
6792                            if self.persist_state().is_ok() && removed.is_some() {
6793                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6794                                    lease_id: lease.lease_id.clone(),
6795                                });
6796                            }
6797                        }
6798                    _ => self.mark_managed_recovery_required(
6799                        &lease.lease_id,
6800                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6801                    ),
6802                }
6803                continue;
6804            };
6805            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6806                &lease,
6807                &listed.path,
6808                listed.branch.as_deref(),
6809                &listed.head,
6810            ) {
6811                self.mark_managed_recovery_required(
6812                    &lease.lease_id,
6813                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6814                );
6815                continue;
6816            }
6817            if !self.workspaces.read()
6818                .unwrap_or_else(|poisoned| poisoned.into_inner())
6819                .contains_key(&lease.workspace_id)
6820            {
6821                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6822                    self.mark_managed_recovery_required(
6823                        &lease.lease_id,
6824                        ManagedWorktreeCleanupFailure::Backend,
6825                    );
6826                    continue;
6827                }
6828            }
6829            let snapshot = {
6830                let mut registry = self.managed_worktrees.lock()
6831                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6832                let current = registry.get_mut(&lease.lease_id)
6833                    .expect("reconciled managed lease remains present");
6834                current.expected_head = Some(listed.head.clone());
6835                current.cleanup_failure = None;
6836                current.state = if current.has_holders() {
6837                    ManagedWorktreeLeaseState::InUse
6838                } else if current.retention == ManagedWorktreeRetention::Retain {
6839                    ManagedWorktreeLeaseState::Retained
6840                } else {
6841                    ManagedWorktreeLeaseState::Ready
6842                };
6843                current.updated_at_unix_ms = unix_time_ms();
6844                current.snapshot()
6845            };
6846            if self.persist_state().is_err() {
6847                self.mark_managed_recovery_required(
6848                    &lease.lease_id,
6849                    ManagedWorktreeCleanupFailure::Backend,
6850                );
6851                continue;
6852            }
6853            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6854            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6855                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6856            }
6857        }
6858    }
6859
6860    /// Crash-recovery startup sweep: durably capture the pack for every
6861    /// `Live` managed record whose most recent session has not yet produced
6862    /// one (recovers a session whose `Exited{0}` event was observed but the
6863    /// Node crashed before the export finished). The reactive fast path for
6864    /// a single just-exited record does not go through this — see
6865    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6866    /// it can capture the exact `SessionAddress` before `publish_control`
6867    /// clears it, rather than re-deriving it from (by then likely already
6868    /// `Dormant`) record state the way a rescan naturally would.
6869    ///
6870    /// Returns as soon as every eligible record's export has been *spawned*,
6871    /// not completed — each one runs on its own detached task (see
6872    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6873    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6874    /// point this is called from `NodeServer::run()`, so nothing could ever
6875    /// drain the history round trip if this awaited it inline here either).
6876    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6877    /// is a silent skip for THIS record — an unusable source or a racing
6878    /// record is not surfaced to any caller.
6879    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6880        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6881            .session_records
6882            .lock()
6883            .unwrap_or_else(|poisoned| poisoned.into_inner())
6884            .records
6885            .values()
6886            .filter(|record| {
6887                record.state == ManagedSessionState::Live
6888                    && record.exported_context.is_none()
6889                    && record.provider_session.is_some()
6890            })
6891            .filter_map(|record| {
6892                record
6893                    .active_session
6894                    .clone()
6895                    .map(|session| (record.record_id.clone(), session))
6896            })
6897            .collect();
6898        for (record_id, session) in candidates {
6899            let shared = Arc::clone(self);
6900            tokio::spawn(async move {
6901                shared
6902                    .reconcile_context_pack_export_for_record(&record_id, session)
6903                    .await;
6904            });
6905        }
6906    }
6907
6908    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
6909    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
6910    /// round trip this performs (`discover_history`/`load_history`, via
6911    /// `export_context_pack_for_session_record`) can only ever settle once
6912    /// `NativeRuntime::tick()` runs again to drain the authority worker's
6913    /// completion — and `tick()` is exclusively owned by, and only ever
6914    /// called from, that same drive-loop task. Awaiting this inline there
6915    /// would starve `tick()` and this call could never observe its own
6916    /// command complete.
6917    ///
6918    /// Because this now genuinely races the drive loop's own
6919    /// `publish_control` (which downgrades this exact record from `Live` to
6920    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
6921    /// triggered this reconcile), the record legitimately may already be
6922    /// `Dormant` by the time this reads it, or may transition mid-flight —
6923    /// `allow_clean_detachment: true` on the downstream export call is what
6924    /// tolerates exactly that transition (and only that transition; see
6925    /// `session_record_export_target_matches`).
6926    async fn reconcile_context_pack_export_for_record(
6927        &self,
6928        record_id: &SessionRecordId,
6929        session: SessionAddress,
6930    ) {
6931        let Ok(record) = self.record(record_id) else {
6932            return;
6933        };
6934        if !matches!(
6935            record.state,
6936            ManagedSessionState::Live | ManagedSessionState::Dormant
6937        ) || record.exported_context.is_some()
6938            || record.provider_session.is_none()
6939        {
6940            return;
6941        }
6942        // Pack bytes are already durably committed inside this call, via
6943        // commit_context_pack_for_session_record's own durable
6944        // write-through (context_pack_store) — no separate step here.
6945        // Unclean exits (non-zero/forced/no loadable history) are already
6946        // rejected by context_pack_source_status_is_usable inside
6947        // materialize_context_pack: this simply returns Err and is skipped.
6948        let Ok(receipt) = self
6949            .export_context_pack_for_session_record(&record.record_id, &session, true)
6950            .await
6951        else {
6952            return;
6953        };
6954        self.set_exported_context(&record.record_id, receipt);
6955    }
6956
6957    /// Records the record's most recent clean-exit pack. No-op (not an
6958    /// error) if the record disappeared, or if it already carries an
6959    /// `exported_context` — first-write-wins, matching the harness-side
6960    /// idempotent reconciliation that consumes this field.
6961    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6962        let transaction = self
6963            .state_transaction
6964            .lock()
6965            .unwrap_or_else(|poisoned| poisoned.into_inner());
6966        let (previous, updated) = {
6967            let mut records = self
6968                .session_records
6969                .lock()
6970                .unwrap_or_else(|poisoned| poisoned.into_inner());
6971            let Some(record) = records.records.get_mut(record_id) else {
6972                return;
6973            };
6974            if record.exported_context.is_some() {
6975                return;
6976            }
6977            let previous = record.clone();
6978            record.exported_context = Some(receipt);
6979            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6980            (previous, record.clone())
6981        };
6982        if self.persist_state_locked().is_err() {
6983            self.session_records
6984                .lock()
6985                .unwrap_or_else(|poisoned| poisoned.into_inner())
6986                .records
6987                .insert(record_id.clone(), previous);
6988            return;
6989        }
6990        drop(transaction);
6991        self.publish_record(updated);
6992    }
6993
6994    /// Resolves the managed record and its exact `SessionAddress` currently
6995    /// bound to `instance_id` at exactly `generation`, used by the reactive
6996    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
6997    /// record without a full rescan. Mirrors the lookup already inlined in
6998    /// `publish_control`. Captured synchronously, before `publish_control`
6999    /// for the same event clears the record's own `active_session` — the
7000    /// caller passes the resolved address through to a detached
7001    /// reconciliation task rather than re-deriving it from the record later,
7002    /// after that race has necessarily already happened.
7003    fn managed_record_export_target_for_instance(
7004        &self,
7005        instance_id: AgentInstanceId,
7006        generation: SessionGeneration,
7007    ) -> Option<(SessionRecordId, SessionAddress)> {
7008        let bindings = self
7009            .session_bindings
7010            .lock()
7011            .unwrap_or_else(|poisoned| poisoned.into_inner());
7012        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7013        let record_id = binding.record_id.clone()?;
7014        Some((
7015            record_id,
7016            SessionAddress {
7017                workspace_id: binding.workspace_id.clone(),
7018                session: SessionKey { instance_id, generation },
7019            },
7020        ))
7021    }
7022
7023    fn mark_managed_recovery_required(
7024        &self,
7025        lease_id: &ManagedWorktreeLeaseId,
7026        failure_kind: ManagedWorktreeCleanupFailure,
7027    ) {
7028        let snapshot = {
7029            let mut registry = self.managed_worktrees.lock()
7030                .unwrap_or_else(|poisoned| poisoned.into_inner());
7031            let Some(lease) = registry.get_mut(lease_id) else { return };
7032            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7033            lease.cleanup_failure = Some(failure_kind);
7034            lease.updated_at_unix_ms = unix_time_ms();
7035            lease.snapshot()
7036        };
7037        let _ = self.persist_state();
7038        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7039    }
7040
7041    fn set_persistence_error(&self, error: Option<String>) {
7042        *self
7043            .persistence_error
7044            .write()
7045            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7046            .map(|message| session_registry::sanitized_persistence_summary(&message));
7047    }
7048
7049    fn persist_state(&self) -> io::Result<()> {
7050        let _transaction = self
7051            .state_transaction
7052            .lock()
7053            .unwrap_or_else(|poisoned| poisoned.into_inner());
7054        self.persist_state_locked()
7055    }
7056
7057    fn persist_state_locked(&self) -> io::Result<()> {
7058        let workspaces = self
7059            .workspaces
7060            .read()
7061            .unwrap_or_else(|poisoned| poisoned.into_inner())
7062            .clone();
7063        let records = self
7064            .session_records
7065            .lock()
7066            .unwrap_or_else(|poisoned| poisoned.into_inner())
7067            .records
7068            .values()
7069            .cloned()
7070            .collect::<Vec<_>>();
7071        let mut managed = self.managed_worktrees
7072            .lock()
7073            .unwrap_or_else(|poisoned| poisoned.into_inner());
7074        managed.reattach_record_holders(
7075            &managed_worktree_record_holders(&records),
7076            unix_time_ms(),
7077        );
7078        let materializations = self
7079            .materializations
7080            .lock()
7081            .unwrap_or_else(|poisoned| poisoned.into_inner())
7082            .values()
7083            .cloned()
7084            .collect::<Vec<_>>();
7085        let managed_spawn_replays = self.managed_spawn_replays
7086            .lock()
7087            .unwrap_or_else(|poisoned| poisoned.into_inner())
7088            .values()
7089            .cloned()
7090            .collect::<Vec<_>>();
7091        let result = session_registry::save_v10(
7092            self.state_path.as_deref(),
7093            &self.node_id,
7094            &workspaces,
7095            &records,
7096            &managed.records(),
7097            &managed.tombstones(),
7098            &materializations,
7099            &managed_spawn_replays,
7100        );
7101        match &result {
7102            Ok(warning) => self.set_persistence_error(warning.clone()),
7103            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7104        }
7105        result.map(|_| ())
7106    }
7107
7108    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7109        let _mutation_guard = self.mutation_gate.lock().await;
7110        self.begin_shutdown_locked().await
7111    }
7112
7113    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7114        if self.shutdown.swap(true, Ordering::AcqRel) {
7115            return Ok(());
7116        }
7117        SessionLifecycleHook::on_shutdown(self);
7118        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7119        let mut first_error = None;
7120        for session in &self.handle.snapshot().sessions {
7121            let remaining = deadline.saturating_duration_since(Instant::now());
7122            if remaining.is_zero() {
7123                first_error.get_or_insert_with(|| {
7124                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7125                });
7126                break;
7127            }
7128            if let Err(error) = self
7129                .dispatch_bounded(
7130                    ControlCommand::Stop {
7131                        instance_id: session.instance_id,
7132                        force: true,
7133                    },
7134                    remaining,
7135                )
7136                .await
7137            {
7138                first_error.get_or_insert(error);
7139            }
7140        }
7141        self.shutdown_notify.notify_waiters();
7142        match first_error {
7143            Some(error) => Err(error),
7144            None => Ok(()),
7145        }
7146    }
7147
7148    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7149        let bindings = self
7150            .session_bindings
7151            .lock()
7152            .unwrap_or_else(|poisoned| poisoned.into_inner());
7153        if bindings.len() >= CONTROL_SESSIONS_MAX {
7154            Err(failure(
7155                NodeFailureCode::BackendBusy,
7156                "node session-binding capacity is full; remove an existing session first",
7157            ))
7158        } else {
7159            Ok(())
7160        }
7161    }
7162
7163    /// One-owner workspace/env collision guard: a live session on
7164    /// `workspace_id` with a different `environment_profile` (option
7165    /// equality on the resolved receipt, including Some vs None) blocks
7166    /// another spawn on that same cwd. Same profile may still share the
7167    /// workspace; distinct profiles need distinct workspace ids (managed
7168    /// worktree).
7169    fn ensure_workspace_environment_profile_compatible(
7170        &self,
7171        workspace_id: &WorkspaceId,
7172        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7173    ) -> Result<(), NodeFailure> {
7174        let bindings = self
7175            .session_bindings
7176            .lock()
7177            .unwrap_or_else(|poisoned| poisoned.into_inner());
7178        for binding in bindings.values() {
7179            if &binding.workspace_id != workspace_id {
7180                continue;
7181            }
7182            if binding.environment_profile.as_ref() != environment_profile {
7183                return Err(failure(
7184                    NodeFailureCode::WorkspaceBusy,
7185                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7186                ));
7187            }
7188        }
7189        Ok(())
7190    }
7191
7192    #[cfg(test)]
7193    fn bind_session(
7194        &self,
7195        instance_id: AgentInstanceId,
7196        workspace_id: WorkspaceId,
7197        generation: SessionGeneration,
7198    ) {
7199        self.bind_session_with_policy(
7200            &SessionAddress {
7201                workspace_id,
7202                session: SessionKey { instance_id, generation },
7203            },
7204            ProviderRuntimePolicy::raw_pty(),
7205            None,
7206        );
7207    }
7208
7209    #[cfg(test)]
7210    fn bind_session_with_policy(
7211        &self,
7212        address: &SessionAddress,
7213        runtime_policy: ProviderRuntimePolicy,
7214        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7215    ) {
7216        self.bind_session_with_materialization(
7217            address,
7218            runtime_policy,
7219            environment_profile,
7220            None,
7221            None,
7222            None,
7223        );
7224    }
7225
7226    fn bind_session_with_materialization(
7227        &self,
7228        address: &SessionAddress,
7229        runtime_policy: ProviderRuntimePolicy,
7230        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7231        bundle: Option<ResolvedBundleReceipt>,
7232        context: Option<ResolvedContextPackReceipt>,
7233        materialization_id: Option<MaterializationId>,
7234    ) {
7235        let mut bindings = self
7236            .session_bindings
7237            .lock()
7238            .unwrap_or_else(|poisoned| poisoned.into_inner());
7239        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7240        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7241        bindings.insert(address.session.instance_id, SessionBinding {
7242            workspace_id: address.workspace_id.clone(),
7243            generation: address.session.generation,
7244            runtime_policy,
7245            pending_resume: None,
7246            record_id: None,
7247            managed_worktree_lease_id: None,
7248            environment_profile,
7249            bundle,
7250            context,
7251            materialization_id,
7252        });
7253    }
7254
7255    #[cfg(test)]
7256    fn bind_managed_session(
7257        &self,
7258        address: &SessionAddress,
7259        record_id: SessionRecordId,
7260        runtime_policy: ProviderRuntimePolicy,
7261        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7262    ) {
7263        self.bind_managed_session_with_materialization(
7264            address,
7265            record_id,
7266            runtime_policy,
7267            environment_profile,
7268            None,
7269            None,
7270            None,
7271        );
7272    }
7273
7274    fn bind_managed_session_with_materialization(
7275        &self,
7276        address: &SessionAddress,
7277        record_id: SessionRecordId,
7278        runtime_policy: ProviderRuntimePolicy,
7279        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7280        bundle: Option<ResolvedBundleReceipt>,
7281        context: Option<ResolvedContextPackReceipt>,
7282        materialization_id: Option<MaterializationId>,
7283    ) {
7284        let mut bindings = self
7285            .session_bindings
7286            .lock()
7287            .unwrap_or_else(|poisoned| poisoned.into_inner());
7288        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7289        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7290        bindings.insert(
7291            address.session.instance_id,
7292            SessionBinding {
7293                workspace_id: address.workspace_id.clone(),
7294                generation: address.session.generation,
7295                runtime_policy,
7296                pending_resume: None,
7297                record_id: Some(record_id),
7298                managed_worktree_lease_id: None,
7299                environment_profile,
7300                bundle,
7301                context,
7302                materialization_id,
7303            },
7304        );
7305    }
7306
7307    #[cfg(test)]
7308    fn bind_spawn_session(
7309        &self,
7310        address: &SessionAddress,
7311        provider: AgentId,
7312        mode: SessionMode,
7313        runtime_policy: ProviderRuntimePolicy,
7314        record_policy: SpawnRecordPolicy,
7315        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7316    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7317        self.bind_spawn_session_with_materialization(
7318            address,
7319            provider,
7320            mode,
7321            runtime_policy,
7322            record_policy,
7323            environment_profile,
7324            None,
7325            None,
7326            None,
7327        )
7328    }
7329
7330    fn bind_spawn_session_with_materialization(
7331        &self,
7332        address: &SessionAddress,
7333        provider: AgentId,
7334        mode: SessionMode,
7335        runtime_policy: ProviderRuntimePolicy,
7336        record_policy: SpawnRecordPolicy,
7337        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7338        bundle: Option<ResolvedBundleReceipt>,
7339        context: Option<ResolvedContextPackReceipt>,
7340        materialization_id: Option<MaterializationId>,
7341    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7342        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7343            && !runtime_policy.provider_session_identity
7344        {
7345            self.bind_session_with_materialization(
7346                address,
7347                runtime_policy,
7348                environment_profile,
7349                bundle,
7350                context,
7351                materialization_id,
7352            );
7353            return Ok(None);
7354        }
7355        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7356        let record = self.new_record(
7357            address,
7358            provider,
7359            mode,
7360            active_record_state(runtime_policy.provider_session_identity, false),
7361            None,
7362            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7363            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7364            record_owns_lifecycle.then(|| context.clone()).flatten(),
7365        )?;
7366        let record_id = record.record_id.clone();
7367        let transaction = self
7368            .state_transaction
7369            .lock()
7370            .unwrap_or_else(|poisoned| poisoned.into_inner());
7371        self.insert_record(record.clone())?;
7372        let previous_materialization = if record_owns_lifecycle {
7373            if let Some(materialization_id) = materialization_id.as_ref() {
7374                let mut materializations = self
7375                    .materializations
7376                    .lock()
7377                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7378                let Some(ownership) = materializations.get_mut(materialization_id) else {
7379                    drop(materializations);
7380                    self.remove_record_memory(&record_id);
7381                    return Err(failure(
7382                        NodeFailureCode::BackendOperationFailed,
7383                        "session environment ownership disappeared before binding",
7384                    ));
7385                };
7386                let previous = ownership.clone();
7387                if ownership
7388                    .transfer_to_record(record_id.clone(), unix_time_ms())
7389                    .is_err()
7390                {
7391                    drop(materializations);
7392                    self.remove_record_memory(&record_id);
7393                    return Err(failure(
7394                        NodeFailureCode::BackendOperationFailed,
7395                        "session environment ownership transfer failed",
7396                    ));
7397                }
7398                Some(previous)
7399            } else {
7400                None
7401            }
7402        } else {
7403            None
7404        };
7405        self.bind_managed_session_with_materialization(
7406            address,
7407            record_id.clone(),
7408            runtime_policy,
7409            environment_profile,
7410            bundle,
7411            context,
7412            materialization_id.clone(),
7413        );
7414        if let Err(error) = self.persist_state_locked() {
7415            self.remove_binding(address);
7416            self.remove_record_memory(&record_id);
7417            if let (Some(materialization_id), Some(previous)) =
7418                (materialization_id.as_ref(), previous_materialization)
7419            {
7420                self.materializations
7421                    .lock()
7422                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7423                    .insert(materialization_id.clone(), previous);
7424            }
7425            return Err(persistence_failure(error));
7426        }
7427        drop(transaction);
7428        self.publish_record(record);
7429        Ok(Some(record_id))
7430    }
7431
7432    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7433        for _ in 0..8 {
7434            let bytes = random_nonce().map_err(|error| {
7435                failure(
7436                    NodeFailureCode::BackendOperationFailed,
7437                    &format!("session record identity generation failed: {error}"),
7438                )
7439            })?;
7440            let mut value = String::with_capacity(27);
7441            value.push_str("sr-");
7442            for byte in &bytes[..12] {
7443                use std::fmt::Write as _;
7444                let _ = write!(&mut value, "{byte:02x}");
7445            }
7446            let record_id = SessionRecordId::new(value).map_err(|error| {
7447                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7448            })?;
7449            let records = self
7450                .session_records
7451                .lock()
7452                .unwrap_or_else(|poisoned| poisoned.into_inner());
7453            if !records.records.contains_key(&record_id) {
7454                return Ok(record_id);
7455            }
7456        }
7457        Err(failure(
7458            NodeFailureCode::BackendBusy,
7459            "could not allocate a unique session record ID",
7460        ))
7461    }
7462
7463    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7464        for _ in 0..8 {
7465            let bytes = random_nonce().map_err(|error| {
7466                failure(
7467                    NodeFailureCode::BackendOperationFailed,
7468                    &format!("task identity generation failed: {error}"),
7469                )
7470            })?;
7471            let mut nonce = [0_u8; 12];
7472            nonce.copy_from_slice(&bytes[..12]);
7473            let task_id = TaskId::from_nonce(nonce);
7474            let records = self
7475                .session_records
7476                .lock()
7477                .unwrap_or_else(|poisoned| poisoned.into_inner());
7478            if !records.records.values().any(|record| {
7479                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7480                    == Some(&task_id)
7481            }) {
7482                return Ok(task_id);
7483            }
7484        }
7485        Err(failure(
7486            NodeFailureCode::BackendBusy,
7487            "could not allocate a unique task ID",
7488        ))
7489    }
7490
7491    fn default_record_name(&self, provider: &AgentId) -> String {
7492        let records = self
7493            .session_records
7494            .lock()
7495            .unwrap_or_else(|poisoned| poisoned.into_inner());
7496        let ordinal = records
7497            .records
7498            .values()
7499            .filter(|record| &record.provider == provider)
7500            .count()
7501            .saturating_add(1);
7502        format!("{} #{ordinal}", provider.as_str())
7503    }
7504
7505    fn new_record(
7506        &self,
7507        address: &SessionAddress,
7508        provider: AgentId,
7509        mode: SessionMode,
7510        state: ManagedSessionState,
7511        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7512        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7513        bundle: Option<ResolvedBundleReceipt>,
7514        context: Option<ResolvedContextPackReceipt>,
7515    ) -> Result<ManagedSessionRecord, NodeFailure> {
7516        let canonical_root = self.workspace_root(&address.workspace_id)?;
7517        let now = unix_time_ms();
7518        Ok(ManagedSessionRecord {
7519            record_id: self.allocate_record_id()?,
7520            display_name: self.default_record_name(&provider),
7521            provider,
7522            mode,
7523            state,
7524            workspace_id: address.workspace_id.clone(),
7525            canonical_root: opaque_windows_path(canonical_root),
7526            provider_session,
7527            active_session: Some(address.clone()),
7528            environment_profile,
7529            bundle,
7530            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7531            context,
7532            exported_context: None,
7533            task_binding: None,
7534            created_at_unix_ms: now,
7535            updated_at_unix_ms: now,
7536            last_error: None,
7537        })
7538    }
7539
7540    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7541        let mut records = self
7542            .session_records
7543            .lock()
7544            .unwrap_or_else(|poisoned| poisoned.into_inner());
7545        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7546            return Err(failure(
7547                NodeFailureCode::BackendBusy,
7548                "managed session record capacity is full; forget an inactive record first",
7549            ));
7550        }
7551        if let Some(identity) = record.provider_session.as_ref() {
7552            if records.records.values().any(|candidate| {
7553                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7554                    session_registry::same_provider_session(
7555                        &candidate.provider,
7556                        candidate_identity,
7557                        &record.provider,
7558                        identity,
7559                    )
7560                })
7561            }) {
7562                return Err(failure(
7563                    NodeFailureCode::SessionRecordConflict,
7564                    "provider session is already represented by another managed record",
7565                ));
7566            }
7567        }
7568        match records.records.entry(record.record_id.clone()) {
7569            std::collections::btree_map::Entry::Vacant(entry) => {
7570                entry.insert(record);
7571            }
7572            std::collections::btree_map::Entry::Occupied(_) => {
7573                return Err(failure(
7574                    NodeFailureCode::SessionRecordConflict,
7575                    "session record ID collision",
7576                ));
7577            }
7578        }
7579        Ok(())
7580    }
7581
7582    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7583        self.session_records
7584            .lock()
7585            .unwrap_or_else(|poisoned| poisoned.into_inner())
7586            .records
7587            .get(record_id)
7588            .cloned()
7589            .ok_or_else(|| {
7590                failure(
7591                    NodeFailureCode::UnknownSessionRecord,
7592                    "managed session record does not exist",
7593                )
7594            })
7595    }
7596
7597    fn revalidate_session_record_preview(
7598        &self,
7599        expected: &ManagedSessionRecord,
7600        identity: &ProviderSessionIdentity,
7601    ) -> Result<(), NodeFailure> {
7602        let current = self.record(&expected.record_id)?;
7603        let current_root = self.workspace_root(&current.workspace_id)?;
7604        if current.provider != expected.provider
7605            || current.workspace_id != expected.workspace_id
7606            || current.canonical_root != expected.canonical_root
7607            || current.provider_session.as_ref() != Some(identity)
7608            || !platform::roots_equal(
7609                &current_root,
7610                windows_path_text(&current.canonical_root),
7611            )
7612        {
7613            return Err(failure(
7614                NodeFailureCode::SessionRecordConflict,
7615                "managed session changed while its preview was loading",
7616            ));
7617        }
7618        Ok(())
7619    }
7620
7621    fn revalidate_session_record_context_export(
7622        &self,
7623        expected: &ManagedSessionRecord,
7624        identity: Option<&ProviderSessionIdentity>,
7625        session: &SessionAddress,
7626        allow_clean_detachment: bool,
7627    ) -> Result<(), NodeFailure> {
7628        let current = self.record(&expected.record_id)?;
7629        let current_root = self.workspace_root(&current.workspace_id)?;
7630        let snapshot = self.internal_session_snapshot(session)?;
7631        let binding = self
7632            .session_bindings
7633            .lock()
7634            .unwrap_or_else(|poisoned| poisoned.into_inner())
7635            .get(&session.session.instance_id)
7636            .cloned()
7637            .ok_or_else(|| {
7638                failure(
7639                    NodeFailureCode::SessionRecordConflict,
7640                    "managed session runtime binding disappeared during context export",
7641                )
7642            })?;
7643        if !session_record_context_export_binding_is_exact(
7644            expected,
7645            &current,
7646            identity,
7647            session,
7648            &binding,
7649            &snapshot.agent_id,
7650            &current_root,
7651            allow_clean_detachment,
7652        ) || !session_record_context_export_source_is_usable(
7653            &current,
7654            &snapshot.status,
7655            allow_clean_detachment,
7656        ) {
7657            return Err(failure(
7658                NodeFailureCode::SessionRecordConflict,
7659                "managed session binding changed during context export",
7660            ));
7661        }
7662        Ok(())
7663    }
7664
7665    fn revalidate_loaded_history(
7666        &self,
7667        session: &SessionAddress,
7668        candidate_id: &str,
7669        loaded: &HistorySessionRecord,
7670    ) -> Result<(), NodeFailure> {
7671        let snapshot = self.internal_session_snapshot(session)?;
7672        if snapshot.history.pending.is_some()
7673            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7674            || snapshot.history.loaded.as_ref() != Some(loaded)
7675        {
7676            return Err(failure(
7677                NodeFailureCode::SessionRecordConflict,
7678                "managed session history changed during context export",
7679            ));
7680        }
7681        Ok(())
7682    }
7683
7684    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7685        self.session_records
7686            .lock()
7687            .unwrap_or_else(|poisoned| poisoned.into_inner())
7688            .records
7689            .remove(record_id)
7690    }
7691
7692    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7693        if self.destroy_record_materialization(record_id)? {
7694            self.publish(NodeEvent::SessionRecordRemoved {
7695                record_id: record_id.clone(),
7696            });
7697            return Ok(());
7698        }
7699        let transaction = self
7700            .state_transaction
7701            .lock()
7702            .unwrap_or_else(|poisoned| poisoned.into_inner());
7703        let Some(removed) = self.remove_record_memory(record_id) else {
7704            return Ok(());
7705        };
7706        if let Err(error) = self.persist_state_locked() {
7707            let _ = self.insert_record(removed);
7708            return Err(persistence_failure(error));
7709        }
7710        drop(transaction);
7711        self.publish(NodeEvent::SessionRecordRemoved {
7712            record_id: record_id.clone(),
7713        });
7714        Ok(())
7715    }
7716
7717    fn publish_record(&self, record: ManagedSessionRecord) {
7718        self.publish(NodeEvent::SessionRecordUpserted { record });
7719    }
7720
7721    fn mark_record_error(
7722        &self,
7723        record_id: &SessionRecordId,
7724        message: &str,
7725    ) -> Result<(), NodeFailure> {
7726        let transaction = self
7727            .state_transaction
7728            .lock()
7729            .unwrap_or_else(|poisoned| poisoned.into_inner());
7730        let previous = {
7731            let mut records = self
7732                .session_records
7733                .lock()
7734                .unwrap_or_else(|poisoned| poisoned.into_inner());
7735            let Some(record) = records.records.get_mut(record_id) else {
7736                return Ok(());
7737            };
7738            let previous = record.clone();
7739            record.state = ManagedSessionState::Unavailable;
7740            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7741            record.updated_at_unix_ms = unix_time_ms();
7742            previous
7743        };
7744        if let Err(error) = self.persist_state_locked() {
7745            self.session_records
7746                .lock()
7747                .unwrap_or_else(|poisoned| poisoned.into_inner())
7748                .records
7749                .insert(record_id.clone(), previous);
7750            return Err(persistence_failure(error));
7751        }
7752        let record = self.record(record_id)?;
7753        drop(transaction);
7754        self.publish_record(record);
7755        Ok(())
7756    }
7757
7758    fn rename_session_record(
7759        &self,
7760        record_id: &SessionRecordId,
7761        display_name: String,
7762    ) -> Result<ManagedSessionRecord, NodeFailure> {
7763        validate_display_name(&display_name)
7764            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7765        let _transaction = self
7766            .state_transaction
7767            .lock()
7768            .unwrap_or_else(|poisoned| poisoned.into_inner());
7769        let previous = {
7770            let mut records = self
7771                .session_records
7772                .lock()
7773                .unwrap_or_else(|poisoned| poisoned.into_inner());
7774            let record = records.records.get_mut(record_id).ok_or_else(|| {
7775                failure(
7776                    NodeFailureCode::UnknownSessionRecord,
7777                    "managed session record does not exist",
7778                )
7779            })?;
7780            let previous = record.clone();
7781            record.display_name = display_name;
7782            record.updated_at_unix_ms = unix_time_ms();
7783            previous
7784        };
7785        if let Err(error) = self.persist_state_locked() {
7786            self.session_records
7787                .lock()
7788                .unwrap_or_else(|poisoned| poisoned.into_inner())
7789                .records
7790                .insert(record_id.clone(), previous);
7791            return Err(persistence_failure(error));
7792        }
7793        let updated = self.record(record_id)?;
7794        self.publish_record(updated.clone());
7795        Ok(updated)
7796    }
7797
7798    fn set_session_task(
7799        &self,
7800        record_id: &SessionRecordId,
7801        expected_revision: u64,
7802        target: SessionTaskTargetV1,
7803    ) -> Result<ManagedSessionRecord, NodeFailure> {
7804        let transaction = self
7805            .state_transaction
7806            .lock()
7807            .unwrap_or_else(|poisoned| poisoned.into_inner());
7808        let current = self.record(record_id)?;
7809        let current_task_id = current
7810            .task_binding
7811            .as_ref()
7812            .and_then(|binding| binding.task_id.as_ref());
7813        let current_revision = current
7814            .task_binding
7815            .as_ref()
7816            .map_or(0, |binding| binding.revision);
7817        if expected_revision != current_revision {
7818            return Err(failure(
7819                NodeFailureCode::SessionRecordConflict,
7820                "managed session task binding revision changed",
7821            ));
7822        }
7823        let exact = match &target {
7824            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7825            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7826            SessionTaskTargetV1::New => false,
7827        };
7828        if exact {
7829            return Ok(current);
7830        }
7831        let revision = current_revision.checked_add(1).ok_or_else(|| {
7832            failure(
7833                NodeFailureCode::SessionRecordConflict,
7834                "managed session task binding revision is exhausted",
7835            )
7836        })?;
7837        let task_id = match target {
7838            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7839            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7840            SessionTaskTargetV1::Clear => None,
7841        };
7842        let changed_at_unix_ms = unix_time_ms()
7843            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7844                failure(
7845                    NodeFailureCode::SessionRecordConflict,
7846                    "managed session record timestamp is exhausted",
7847                )
7848            })?);
7849        let updated = {
7850            let mut records = self
7851                .session_records
7852                .lock()
7853                .unwrap_or_else(|poisoned| poisoned.into_inner());
7854            let record = records.records.get_mut(record_id).ok_or_else(|| {
7855                failure(
7856                    NodeFailureCode::UnknownSessionRecord,
7857                    "managed session record does not exist",
7858                )
7859            })?;
7860            record.task_binding = Some(SessionTaskBindingV1 {
7861                revision,
7862                task_id,
7863                changed_at_unix_ms,
7864            });
7865            record.updated_at_unix_ms = changed_at_unix_ms;
7866            record.clone()
7867        };
7868        if let Err(error) = self.persist_state_locked() {
7869            self.session_records
7870                .lock()
7871                .unwrap_or_else(|poisoned| poisoned.into_inner())
7872                .records
7873                .insert(record_id.clone(), current);
7874            return Err(persistence_failure(error));
7875        }
7876        drop(transaction);
7877        self.publish_record(updated.clone());
7878        Ok(updated)
7879    }
7880
7881    fn index_provider_session(
7882        &self,
7883        workspace_id: WorkspaceId,
7884        provider: AgentId,
7885        identity: ProviderSessionIdentity,
7886        display_name: String,
7887    ) -> Result<ManagedSessionRecord, NodeFailure> {
7888        self.index_provider_session_with_policy(
7889            workspace_id,
7890            provider,
7891            identity,
7892            display_name,
7893            false,
7894        )
7895    }
7896
7897    fn index_provider_session_with_policy(
7898        &self,
7899        workspace_id: WorkspaceId,
7900        provider: AgentId,
7901        identity: ProviderSessionIdentity,
7902        display_name: String,
7903        allow_validated_transcript_path: bool,
7904    ) -> Result<ManagedSessionRecord, NodeFailure> {
7905        validate_display_name(&display_name)
7906            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7907        identity
7908            .validate()
7909            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7910        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7911            return Err(failure(
7912                NodeFailureCode::InvalidRequest,
7913                "provider session reference index v1 does not accept transcript paths",
7914            ));
7915        }
7916        let canonical_root = self.workspace_root(&workspace_id)?;
7917        if !self.enabled_providers.contains(&provider) {
7918            return Err(failure(
7919                NodeFailureCode::InvalidRequest,
7920                "provider is not enabled on this node",
7921            ));
7922        }
7923
7924        let transaction = self
7925            .state_transaction
7926            .lock()
7927            .unwrap_or_else(|poisoned| poisoned.into_inner());
7928        {
7929            let records = self
7930                .session_records
7931                .lock()
7932                .unwrap_or_else(|poisoned| poisoned.into_inner());
7933            if let Some(existing) = records.records.values().find(|candidate| {
7934                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7935                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
7936                })
7937            }) {
7938                if existing.provider == provider && existing.workspace_id == workspace_id {
7939                    return Ok(existing.clone());
7940                }
7941                return Err(failure(
7942                    NodeFailureCode::SessionRecordConflict,
7943                    "provider session reference conflicts with another provider or workspace",
7944                ));
7945            }
7946            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7947                return Err(failure(
7948                    NodeFailureCode::BackendBusy,
7949                    "managed session record capacity is full; forget an inactive record first",
7950                ));
7951            }
7952        }
7953
7954        let now = unix_time_ms();
7955        let record = ManagedSessionRecord {
7956            record_id: self.allocate_record_id()?,
7957            display_name,
7958            provider,
7959            mode: SessionMode::Pty,
7960            state: ManagedSessionState::Dormant,
7961            workspace_id,
7962            canonical_root: opaque_windows_path(canonical_root),
7963            provider_session: Some(identity),
7964            active_session: None,
7965            environment_profile: None,
7966            bundle: None,
7967            context_id: None,
7968            context: None,
7969            exported_context: None,
7970            task_binding: None,
7971            created_at_unix_ms: now,
7972            updated_at_unix_ms: now,
7973            last_error: None,
7974        };
7975        self.insert_record(record.clone())?;
7976        if let Err(error) = self.persist_state_locked() {
7977            self.remove_record_memory(&record.record_id);
7978            return Err(persistence_failure(error));
7979        }
7980        drop(transaction);
7981        self.publish_record(record.clone());
7982        Ok(record)
7983    }
7984
7985    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7986        self.session_bindings
7987            .lock()
7988            .unwrap_or_else(|poisoned| poisoned.into_inner())
7989            .iter()
7990            .find_map(|(instance_id, binding)| {
7991                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7992                    workspace_id: binding.workspace_id.clone(),
7993                    session: SessionKey {
7994                        instance_id: *instance_id,
7995                        generation: binding.generation,
7996                    },
7997                })
7998            })
7999    }
8000
8001    async fn forget_session_record(
8002        &self,
8003        record_id: &SessionRecordId,
8004    ) -> Result<(), NodeFailure> {
8005        let record = self.record(record_id)?;
8006        if record.active_session.is_some()
8007            || matches!(
8008                record.state,
8009                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8010            )
8011        {
8012            return Err(failure(
8013                NodeFailureCode::SessionRecordBusy,
8014                "stop the managed session before forgetting it",
8015            ));
8016        }
8017        if let Some(address) = self.bound_address_for_record(record_id) {
8018            self.remove_session(&address).await?;
8019        }
8020        self.remove_managed_session_record(record_id, &record.workspace_id).await
8021    }
8022
8023    /// Removes one managed session record from durable state and notifies
8024    /// every listener -- the shared transaction body behind both
8025    /// `forget_session_record`'s operator-driven path (busy-checked above
8026    /// it, and clears any still-live session binding before calling in
8027    /// here) and the retention sweep (`retire_unavailable_session_records`,
8028    /// which only ever selects `ManagedSessionState::Unavailable` records
8029    /// that are already known never-busy and never bound, so it calls
8030    /// straight into this with neither check). Cleans a managed-worktree
8031    /// lease if the record held one, removes the record's environment
8032    /// materialization (or, absent one, removes it from memory directly),
8033    /// persists under `state_transaction` with rollback on persist failure,
8034    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8035    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8036    /// exactly as it does for an operator-driven forget.
8037    async fn remove_managed_session_record(
8038        &self,
8039        record_id: &SessionRecordId,
8040        workspace_id: &WorkspaceId,
8041    ) -> Result<(), NodeFailure> {
8042        let managed_lease_id = self.managed_worktrees.lock()
8043            .unwrap_or_else(|poisoned| poisoned.into_inner())
8044            .lease_for_workspace(workspace_id);
8045        if !self.destroy_record_materialization(record_id)? {
8046            let transaction = self
8047                .state_transaction
8048                .lock()
8049                .unwrap_or_else(|poisoned| poisoned.into_inner());
8050            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8051                failure(
8052                    NodeFailureCode::UnknownSessionRecord,
8053                    "managed session record does not exist",
8054                )
8055            })?;
8056            if let Err(error) = self.persist_state_locked() {
8057                let _ = self.insert_record(removed);
8058                return Err(persistence_failure(error));
8059            }
8060            drop(transaction);
8061        }
8062        self.publish(NodeEvent::SessionRecordRemoved {
8063            record_id: record_id.clone(),
8064        });
8065        if let Some(lease_id) = managed_lease_id {
8066            if let Some(snapshot) = self.managed_worktrees.lock()
8067                .unwrap_or_else(|poisoned| poisoned.into_inner())
8068                .get(&lease_id)
8069                .map(ManagedWorktreeLeaseRecord::snapshot)
8070            {
8071                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8072            }
8073            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8074        }
8075        Ok(())
8076    }
8077
8078    /// Retires `ManagedSessionState::Unavailable` records against the
8079    /// node's configured `session_record_retention` age/keep-N policy.
8080    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8081    /// `Dormant` is resumable and folding it into this sweep would delete
8082    /// records a caller can still act on. Both policy fields default to
8083    /// `0` (disabled); when both are `0` this is a no-op. When both are
8084    /// set, a record is retired only if it fails BOTH tests (older than
8085    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8086    /// workspace); when only one is set, that one alone decides. Called
8087    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8088    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8089    /// loop -- see both call sites.
8090    async fn retire_unavailable_session_records(&self) {
8091        let policy = self.session_record_retention;
8092        let age_enabled = policy.age_ms > 0;
8093        let keep_enabled = policy.keep_per_workspace > 0;
8094        if !age_enabled && !keep_enabled {
8095            return;
8096        }
8097        let now = unix_time_ms();
8098        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8099            self.session_records
8100                .lock()
8101                .unwrap_or_else(|poisoned| poisoned.into_inner())
8102                .records
8103                .values()
8104                .filter(|record| record.state == ManagedSessionState::Unavailable)
8105                .map(|record| {
8106                    (
8107                        record.record_id.clone(),
8108                        record.workspace_id.clone(),
8109                        record.provider.clone(),
8110                        record.updated_at_unix_ms,
8111                    )
8112                })
8113                .collect()
8114        };
8115        if candidates.is_empty() {
8116            return;
8117        }
8118        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8119            BTreeMap::new();
8120        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8121            by_workspace
8122                .entry(workspace_id)
8123                .or_default()
8124                .push((record_id, provider, updated_at_unix_ms));
8125        }
8126        let reason = if age_enabled && keep_enabled {
8127            "age+keep-n"
8128        } else if age_enabled {
8129            "age"
8130        } else {
8131            "keep-n"
8132        };
8133        let mut retired: u64 = 0;
8134        for (workspace_id, mut records) in by_workspace {
8135            // Newest `updated_at_unix_ms` first, so index `>=
8136            // keep_per_workspace` names exactly "beyond the newest N".
8137            records.sort_by(|left, right| right.2.cmp(&left.2));
8138            for (index, (record_id, provider, updated_at_unix_ms)) in
8139                records.into_iter().enumerate()
8140            {
8141                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8142                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8143                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8144                let should_retire = if age_enabled && keep_enabled {
8145                    age_hit && keep_hit
8146                } else {
8147                    age_hit || keep_hit
8148                };
8149                if !should_retire {
8150                    continue;
8151                }
8152                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8153                    Ok(()) => {
8154                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8155                        retired += 1;
8156                        tracing::info!(
8157                            node_id = %self.node_id,
8158                            record_id = %record_id,
8159                            workspace_id = %workspace_id,
8160                            provider = %provider,
8161                            age_ms = record_age_ms,
8162                            reason,
8163                            "managed session record retired"
8164                        );
8165                    }
8166                    Err(error) => tracing::warn!(
8167                        node_id = %self.node_id,
8168                        record_id = %record_id,
8169                        workspace_id = %workspace_id,
8170                        code = ?error.code,
8171                        cause = %error.message,
8172                        "session record retention sweep failed to retire a record"
8173                    ),
8174                }
8175            }
8176        }
8177        if retired > 0 {
8178            let remaining_unavailable = self
8179                .session_records
8180                .lock()
8181                .unwrap_or_else(|poisoned| poisoned.into_inner())
8182                .records
8183                .values()
8184                .filter(|record| record.state == ManagedSessionState::Unavailable)
8185                .count();
8186            tracing::info!(
8187                node_id = %self.node_id,
8188                retired,
8189                remaining_unavailable,
8190                "session record retention sweep completed"
8191            );
8192        }
8193    }
8194
8195    async fn resume_session_record(
8196        &self,
8197        record_id: &SessionRecordId,
8198        terminal_size: gate4agent_types::TerminalSize,
8199        initial_prompt: Option<String>,
8200    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8201        let record = self.record(record_id)?;
8202        if record.active_session.is_some()
8203            || matches!(
8204                record.state,
8205                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8206            )
8207        {
8208            return Err(failure(
8209                NodeFailureCode::SessionRecordBusy,
8210                "managed session already has a live runtime binding",
8211            ));
8212        }
8213        let identity = record.provider_session.clone().ok_or_else(|| {
8214            failure(
8215                NodeFailureCode::SessionRecordNotResumable,
8216                "managed session has no verified provider session identity",
8217            )
8218        })?;
8219        let working_directory = self.workspace_root(&record.workspace_id)?;
8220        if !platform::roots_equal(
8221            &working_directory,
8222            windows_path_text(&record.canonical_root),
8223        ) {
8224            return Err(failure(
8225                NodeFailureCode::SessionRecordConflict,
8226                "managed session workspace root changed; refusing to resume in another directory",
8227            ));
8228        }
8229        {
8230            let records = self
8231                .session_records
8232                .lock()
8233                .unwrap_or_else(|poisoned| poisoned.into_inner());
8234            if records.records.values().any(|candidate| {
8235                candidate.record_id != *record_id
8236                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8237                        session_registry::same_provider_session(
8238                            &candidate.provider,
8239                            candidate_identity,
8240                            &record.provider,
8241                            &identity,
8242                        )
8243                    })
8244                    && (candidate.active_session.is_some()
8245                        || matches!(
8246                            candidate.state,
8247                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8248                        ))
8249            }) {
8250                return Err(failure(
8251                    NodeFailureCode::SessionRecordConflict,
8252                    "provider session is already active through another managed record",
8253                ));
8254            }
8255        }
8256        let runtime_requirement = if initial_prompt.is_some() {
8257            ProviderRuntimeRequirement::ResumeWithPrompt
8258        } else {
8259            ProviderRuntimeRequirement::Resume
8260        };
8261        let runtime_policy = self.admit_provider_runtime(
8262            &record.provider,
8263            runtime_requirement,
8264        )
8265        .await?;
8266        let request = ResumeLaunchRequest {
8267            working_directory,
8268            terminal_size,
8269            initial_prompt,
8270        };
8271        request
8272            .validate()
8273            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8274        self.ensure_binding_capacity()?;
8275        let resolved_materialization = match self.resolve_record_materialization(
8276            record_id,
8277            &record.provider,
8278            record.mode,
8279            record.environment_profile.as_ref(),
8280            record.bundle.as_ref(),
8281            record.context.as_ref(),
8282        ) {
8283            Ok(materialization) => materialization,
8284            Err(error) => {
8285                let label = match error.code {
8286                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8287                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8288                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8289                    _ => "environment-profile-unavailable",
8290                };
8291                self.mark_record_error(record_id, label)?;
8292                return Err(error);
8293            }
8294        };
8295        let (materialization_id, environment_overlay) = match resolved_materialization {
8296            Some((id, overlay)) => (Some(id), overlay),
8297            None => (None, None),
8298        };
8299        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8300        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8301            instance_id,
8302            &record.provider,
8303            record.mode,
8304            record.environment_profile.as_ref(),
8305        ) {
8306            Ok(selection) => selection,
8307            Err(error) => {
8308                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8309                return Err(error);
8310            }
8311        };
8312        let mut instance_overlay = environment_overlay
8313            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8314            .transpose()?
8315            .flatten();
8316        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8317            self.remove_session(&stale_address).await?;
8318        }
8319        let address = SessionAddress {
8320            workspace_id: record.workspace_id.clone(),
8321            session: SessionKey {
8322                instance_id,
8323                generation: SessionGeneration(1),
8324            },
8325        };
8326        let transaction = self
8327            .state_transaction
8328            .lock()
8329            .unwrap_or_else(|poisoned| poisoned.into_inner());
8330        let current = self.record(record_id)?;
8331        if current.active_session.is_some()
8332            || matches!(
8333                current.state,
8334                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8335            )
8336        {
8337            return Err(failure(
8338                NodeFailureCode::SessionRecordBusy,
8339                "managed session became active while resume was being prepared",
8340            ));
8341        }
8342        self.bind_managed_session_with_materialization(
8343            &address,
8344            record_id.clone(),
8345            runtime_policy,
8346            record.environment_profile.clone(),
8347            record.bundle.clone(),
8348            record.context.clone(),
8349            materialization_id,
8350        );
8351        if let Some(selection) = environment_selection.take() {
8352            selection.retain();
8353        }
8354        if let Some(overlay) = instance_overlay.take() {
8355            overlay.retain();
8356        }
8357        drop(transaction);
8358
8359        let transport = match record.mode {
8360            SessionMode::Pty => TransportKind::Pty,
8361            SessionMode::Inline => TransportKind::Pipe,
8362            SessionMode::Acp => TransportKind::Acp,
8363        };
8364        let agent_id = record.provider.clone();
8365        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8366        if let Err(error) = self
8367            .dispatch_bounded(
8368                ControlCommand::Register {
8369                    instance_id,
8370                    agent_id,
8371                    transport,
8372                },
8373                dispatch_timeout,
8374            )
8375            .await
8376        {
8377            if let Some(binding) = self.remove_binding(&address) {
8378                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8379            }
8380            return Err(error);
8381        }
8382        let command = self.prepare_command(ControlCommand::Resume {
8383            instance_id,
8384            target: ResumeTarget::ProviderSession { identity },
8385            runtime_policy,
8386            request,
8387        });
8388        let started_after = self.current_sequence();
8389        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8390            self.rollback_spawn(&address, dispatch_timeout).await?;
8391            return Err(error);
8392        }
8393        if let Err(error) = self.dispatch_envelope(command) {
8394            self.clear_armed_resume(&address);
8395            self.rollback_spawn(&address, dispatch_timeout).await?;
8396            return Err(error);
8397        }
8398        match self
8399            .wait_for_record_resume(&address, started_after)
8400            .await
8401        {
8402            Ok(settled_address) => {
8403                let managed_lease_id = {
8404                    self.managed_worktrees.lock()
8405                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8406                        .lease_for_workspace(&record.workspace_id)
8407                };
8408                if let Some(lease_id) = managed_lease_id {
8409                    {
8410                        let mut bindings = self.session_bindings.lock()
8411                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8412                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8413                            .ok_or_else(|| failure(
8414                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8415                                "resumed managed session lost its exact binding",
8416                            ))?;
8417                        if binding.generation != settled_address.session.generation {
8418                            return Err(failure(
8419                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8420                                "resumed managed session generation diverged",
8421                            ));
8422                        }
8423                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8424                    }
8425                    let snapshot = self.managed_worktrees.lock()
8426                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8427                        .bind_session(
8428                            &lease_id,
8429                            ManagedWorktreeSessionHolder {
8430                                incarnation_id: self.incarnation_id,
8431                                instance_id: settled_address.session.instance_id,
8432                                generation: settled_address.session.generation,
8433                            },
8434                            Some(record_id.clone()),
8435                            unix_time_ms(),
8436                        ).map_err(|message| failure(
8437                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8438                            &message,
8439                        ))?;
8440                    if let Err(error) = self.persist_state() {
8441                        let _ = self.remove_session(&settled_address).await;
8442                        return Err(persistence_failure(error));
8443                    }
8444                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8445                }
8446                Ok((self.record(record_id)?, settled_address))
8447            }
8448            Err(error) => {
8449                if matches!(
8450                    error.code,
8451                    NodeFailureCode::SessionRecordNotResumable
8452                        | NodeFailureCode::BackendOperationFailed
8453                ) {
8454                    if let Some(bound) = self.bound_address_for_record(record_id) {
8455                        let _ = self.remove_session(&bound).await;
8456                    }
8457                }
8458                Err(error)
8459            }
8460        }
8461    }
8462
8463    async fn wait_for_record_resume(
8464        &self,
8465        initial_address: &SessionAddress,
8466        after_sequence: u64,
8467    ) -> Result<SessionAddress, NodeFailure> {
8468        let deadline = Instant::now()
8469            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8470        let mut scan_after = after_sequence;
8471        loop {
8472            let events = self
8473                .history
8474                .lock()
8475                .unwrap_or_else(|poisoned| poisoned.into_inner())
8476                .events
8477                .iter()
8478                .filter(|event| event.sequence > scan_after)
8479                .cloned()
8480                .collect::<Vec<_>>();
8481            for envelope in events {
8482                scan_after = scan_after.max(envelope.sequence);
8483                let NodeEvent::Control { address, event } = envelope.event else {
8484                    continue;
8485                };
8486                if address.session.instance_id != initial_address.session.instance_id {
8487                    continue;
8488                }
8489                match event.event {
8490                    ControlEventKind::Resumed { .. } => return Ok(address),
8491                    ControlEventKind::ResumeDenied { reason } => {
8492                        return Err(failure(
8493                            NodeFailureCode::SessionRecordNotResumable,
8494                            &reason,
8495                        ));
8496                    }
8497                    ControlEventKind::ResumeFailed { message }
8498                    | ControlEventKind::CommandRejected { message } => {
8499                        return Err(failure(
8500                            NodeFailureCode::BackendOperationFailed,
8501                            &message,
8502                        ));
8503                    }
8504                    _ => {}
8505                }
8506            }
8507            if Instant::now() >= deadline {
8508                return Err(failure(
8509                    NodeFailureCode::BackendBusy,
8510                    "managed resume did not settle before the bounded deadline",
8511                ));
8512            }
8513            sleep(Duration::from_millis(5)).await;
8514        }
8515    }
8516
8517    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8518        self.workspaces
8519            .read()
8520            .unwrap_or_else(|poisoned| poisoned.into_inner())
8521            .get(workspace_id)
8522            .cloned()
8523            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8524    }
8525
8526    fn workspace_roots(&self) -> Vec<PathBuf> {
8527        self.workspaces
8528            .read()
8529            .unwrap_or_else(|poisoned| poisoned.into_inner())
8530            .values()
8531            .map(PathBuf::from)
8532            .collect()
8533    }
8534
8535    async fn inspect_workspace(
8536        &self,
8537        workspace_id: WorkspaceId,
8538    ) -> Result<WorkspaceInspection, NodeFailure> {
8539        let _permit = self
8540            .inspection_slots
8541            .clone()
8542            .try_acquire_owned()
8543            .map_err(|_| {
8544                tracing::warn!(
8545                    workspace_id = %workspace_id,
8546                    "workspace inspection rejected: inspection capacity is busy",
8547                );
8548                failure(
8549                    NodeFailureCode::BackendBusy,
8550                    "workspace inspection capacity is busy",
8551                )
8552            })?;
8553        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8554            tracing::warn!(
8555                workspace_id = %workspace_id,
8556                code = ?error.code,
8557                "workspace inspection rejected: workspace is not registered",
8558            );
8559            error
8560        })?;
8561        let tree_root = canonical_root.clone();
8562        let started_at = Instant::now();
8563        let time_budget_ms = workspace_inspection_time_budget_ms();
8564        let entry_cap = workspace_inspection_entry_cap();
8565        let deadline = started_at + Duration::from_millis(time_budget_ms);
8566        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8567            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8568        })
8569        .await
8570        .map_err(|error| {
8571            tracing::warn!(
8572                workspace_id = %workspace_id,
8573                error = %error,
8574                "workspace inspection rejected: walk task failed",
8575            );
8576            failure(
8577                NodeFailureCode::BackendOperationFailed,
8578                &format!("workspace inspection task failed: {error}"),
8579            )
8580        })?;
8581        let (mut git, git_time_budget_exceeded) =
8582            inspect_git_workspace(&canonical_root, deadline).await;
8583        if !git.worktrees.is_empty() {
8584            let registered = self
8585                .workspaces
8586                .read()
8587                .unwrap_or_else(|poisoned| poisoned.into_inner())
8588                .iter()
8589                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8590                .collect::<Vec<_>>();
8591            for worktree in &mut git.worktrees {
8592                worktree.workspace_id = registered
8593                    .iter()
8594                    .find(|(_, root)| {
8595                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8596                    })
8597                    .map(|(workspace_id, _)| workspace_id.clone());
8598            }
8599        }
8600        if git.is_repository {
8601            git.managed_worktree = self
8602                .managed_worktrees
8603                .lock()
8604                .unwrap_or_else(|poisoned| poisoned.into_inner())
8605                .git_scope(&workspace_id, git.branch.as_deref());
8606        }
8607        let truncation = workspace_inspection_truncation(
8608            &walk_budget,
8609            git_time_budget_exceeded,
8610            started_at.elapsed(),
8611        );
8612        if let Some(truncation) = &truncation {
8613            tracing::info!(
8614                workspace_id = %workspace_id,
8615                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8616                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8617                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8618                entries_visited = truncation.entries_visited,
8619                elapsed_ms = truncation.elapsed_ms,
8620                "workspace inspection truncated by its inner budget",
8621            );
8622        }
8623        Ok(WorkspaceInspection {
8624            workspace_id,
8625            entries,
8626            tree_truncated,
8627            git,
8628            truncation,
8629        })
8630    }
8631
8632    async fn browse_host_directories(
8633        &self,
8634        directory: Option<OpaqueHostPath>,
8635        after: Option<OpaqueHostPath>,
8636    ) -> Result<HostDirectoryListing, NodeFailure> {
8637        let permit = self
8638            .inspection_slots
8639            .clone()
8640            .try_acquire_owned()
8641            .map_err(|_| {
8642                failure(
8643                    NodeFailureCode::BackendBusy,
8644                    "host directory browse capacity is busy",
8645                )
8646            })?;
8647        let task = tokio::task::spawn_blocking(move || {
8648            let _permit = permit;
8649            browse_host_directories(directory, after)
8650        });
8651        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8652            .await
8653            .map_err(|_| {
8654                failure(
8655                    NodeFailureCode::HostDirectoryReadTimedOut,
8656                    "host directory browse exceeded its bounded deadline",
8657                )
8658            })?
8659            .map_err(|_| {
8660                failure(
8661                    NodeFailureCode::HostDirectoryReadFailed,
8662                    "host directory browse task failed",
8663                )
8664            })?
8665            .map_err(host_directory_failure)
8666    }
8667
8668    async fn read_workspace_file(
8669        &self,
8670        workspace_id: WorkspaceId,
8671        path: RepositoryPath,
8672    ) -> Result<WorkspaceFileRead, NodeFailure> {
8673        let canonical_root = self.workspace_root(&workspace_id)?;
8674        let permit = self
8675            .inspection_slots
8676            .clone()
8677            .try_acquire_owned()
8678            .map_err(|_| {
8679                failure(
8680                    NodeFailureCode::BackendBusy,
8681                    "workspace file read capacity is busy",
8682                )
8683            })?;
8684        let path_to_read = path.clone();
8685        let task = tokio::task::spawn_blocking(move || {
8686            let _permit = permit;
8687            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8688        });
8689        let content = timeout(
8690            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8691            task,
8692        )
8693        .await
8694        .map_err(|_| {
8695            failure(
8696                NodeFailureCode::RepositoryFileReadTimedOut,
8697                "repository file read exceeded its bounded deadline",
8698            )
8699        })?
8700        .map_err(|_| {
8701            failure(
8702                NodeFailureCode::RepositoryFileReadFailed,
8703                "repository file read task failed",
8704            )
8705        })?
8706        .map_err(workspace_file_failure)?;
8707        let content = match content {
8708            WorkspaceFileBytes::Utf8(text) => {
8709                let revision = Some(workspace_file_revision(text.as_bytes()));
8710                return Ok(WorkspaceFileRead {
8711                    workspace_id,
8712                    path,
8713                    content: WorkspaceFileContent::Utf8 {
8714                        byte_len: u32::try_from(text.len())
8715                            .expect("bounded workspace text length must fit u32"),
8716                        text,
8717                    },
8718                    revision,
8719                });
8720            }
8721            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8722                WorkspaceFileContent::NonUtf8 {
8723                    byte_len: u32::try_from(byte_length)
8724                        .expect("bounded workspace file length must fit u32"),
8725                }
8726            }
8727            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8728                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8729            },
8730        };
8731        Ok(WorkspaceFileRead {
8732            workspace_id,
8733            path,
8734            content,
8735            revision: None,
8736        })
8737    }
8738
8739    async fn write_workspace_file(
8740        &self,
8741        workspace_id: WorkspaceId,
8742        path: RepositoryPath,
8743        expected_revision: WorkspaceFileRevision,
8744        text: String,
8745    ) -> Result<WorkspaceFileRead, NodeFailure> {
8746        let canonical_root = self.workspace_root(&workspace_id)?;
8747        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8748            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8749        })?;
8750        let path_to_write = path.clone();
8751        let expected = expected_revision.as_str().to_owned();
8752        let text_to_write = text.clone();
8753        let task = tokio::task::spawn_blocking(move || {
8754            let _permit = permit;
8755            write_workspace_file_to_disk(
8756                Path::new(&canonical_root),
8757                &path_to_write,
8758                &expected,
8759                &text_to_write,
8760            )
8761        });
8762        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8763            .await
8764            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8765            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8766            .map_err(workspace_file_write_failure)?;
8767        Ok(WorkspaceFileRead {
8768            workspace_id,
8769            path,
8770            content: WorkspaceFileContent::Utf8 {
8771                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8772                text,
8773            },
8774            revision: Some(WorkspaceFileRevision::new(revision)
8775                .expect("workspace writer returns a valid SHA-256 revision")),
8776        })
8777    }
8778
8779    async fn create_workspace_file(
8780        &self,
8781        workspace_id: WorkspaceId,
8782        path: RepositoryPath,
8783    ) -> Result<WorkspaceFileRead, NodeFailure> {
8784        let canonical_root = self.workspace_root(&workspace_id)?;
8785        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8786            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8787        })?;
8788        let path_to_create = path.clone();
8789        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8790        let task_commit_state = Arc::clone(&commit_state);
8791        let task = tokio::task::spawn_blocking(move || {
8792            let _permit = permit;
8793            create_workspace_file_on_disk(
8794                Path::new(&canonical_root),
8795                &path_to_create,
8796                &task_commit_state,
8797            )
8798        });
8799        let revision = settle_workspace_entry_create(
8800            task,
8801            commit_state,
8802            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8803            "repository file create exceeded its bounded deadline",
8804            "repository file create task failed",
8805        )
8806        .await?;
8807        Ok(WorkspaceFileRead {
8808            workspace_id,
8809            path,
8810            content: WorkspaceFileContent::Utf8 {
8811                text: String::new(),
8812                byte_len: 0,
8813            },
8814            revision: Some(WorkspaceFileRevision::new(revision)
8815                .expect("workspace creator returns a valid SHA-256 revision")),
8816        })
8817    }
8818
8819    async fn create_workspace_directory(
8820        &self,
8821        workspace_id: WorkspaceId,
8822        path: RepositoryPath,
8823    ) -> Result<WorkspaceEntry, NodeFailure> {
8824        let canonical_root = self.workspace_root(&workspace_id)?;
8825        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8826            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8827        })?;
8828        let path_to_create = path.clone();
8829        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8830        let task_commit_state = Arc::clone(&commit_state);
8831        let task = tokio::task::spawn_blocking(move || {
8832            let _permit = permit;
8833            create_workspace_directory_on_disk(
8834                Path::new(&canonical_root),
8835                &path_to_create,
8836                &task_commit_state,
8837            )
8838        });
8839        settle_workspace_entry_create(
8840            task,
8841            commit_state,
8842            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8843            "repository directory create exceeded its bounded deadline",
8844            "repository directory create task failed",
8845        )
8846        .await?;
8847        Ok(WorkspaceEntry {
8848            relative_path: path,
8849            kind: WorkspaceEntryKind::Directory,
8850        })
8851    }
8852
8853    async fn read_git_history(
8854        &self,
8855        workspace_id: WorkspaceId,
8856        path: Option<RepositoryPath>,
8857        before: Option<GitObjectId>,
8858        limit: u16,
8859    ) -> Result<GitHistoryPage, NodeFailure> {
8860        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8861            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8862        })?;
8863        let root = self.workspace_root(&workspace_id)?;
8864        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8865    }
8866
8867    async fn read_git_diff(
8868        &self,
8869        workspace_id: WorkspaceId,
8870        request: GitDiffRequest,
8871    ) -> Result<GitDiff, NodeFailure> {
8872        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8873            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8874        })?;
8875        let root = self.workspace_root(&workspace_id)?;
8876        read_git_diff_bounded(&root, request).await
8877    }
8878
8879    fn native_session_route_root(
8880        &self,
8881        route: &NativeSessionCatalogRoute,
8882    ) -> Result<Option<String>, NodeFailure> {
8883        route
8884            .validate()
8885            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8886        match route.scope {
8887            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8888                .workspace_id
8889                .as_ref()
8890                .map(|workspace_id| self.workspace_root(workspace_id))
8891                .transpose(),
8892            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8893        }
8894    }
8895
8896    fn project_native_session_entries(
8897        &self,
8898        route: &NativeSessionCatalogRoute,
8899        entries: Vec<ScopedNativeSessionCatalogEntry>,
8900    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8901        let current_root = route
8902            .workspace_id
8903            .as_ref()
8904            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8905        let records = self
8906            .session_records
8907            .lock()
8908            .unwrap_or_else(|poisoned| poisoned.into_inner());
8909        let mut seen_records = std::collections::HashSet::new();
8910        entries
8911            .into_iter()
8912            .map(|entry| {
8913                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8914                    let matches = records
8915                        .records
8916                        .values()
8917                        .filter(|record| {
8918                            record.provider == route.provider
8919                                && record.workspace_id == *workspace_id
8920                                && current_root.as_deref().is_some_and(|root| {
8921                                    platform::roots_equal(
8922                                        root,
8923                                        windows_path_text(&record.canonical_root),
8924                                    )
8925                                })
8926                                && record.provider_session.as_ref().is_some_and(|identity| {
8927                                    session_registry::same_provider_session(
8928                                        &record.provider,
8929                                        identity,
8930                                        &route.provider,
8931                                        &entry.provider_session,
8932                                    )
8933                                })
8934                        })
8935                        .map(|record| record.record_id.clone())
8936                        .collect::<Vec<_>>();
8937                    (matches.len() == 1
8938                        && seen_records.insert(matches[0].clone()))
8939                        .then(|| matches[0].clone())
8940                });
8941                let metadata = entry.metadata;
8942                crate::protocol::NativeSessionCatalogEntry {
8943                    selection_id: metadata.selection_id,
8944                    title: metadata.title,
8945                    modified_at_unix_ms: metadata.modified_at_unix_ms,
8946                    model: metadata.model,
8947                    message_count: metadata.message_count,
8948                    completed_turn_count: metadata.completed_turn_count,
8949                    external_group: entry.external_group,
8950                    record_id,
8951                }
8952            })
8953            .collect()
8954    }
8955
8956    async fn catalog_native_sessions(
8957        &self,
8958        route: NativeSessionCatalogRoute,
8959        limit: u16,
8960    ) -> Result<(
8961        Vec<crate::protocol::NativeSessionCatalogEntry>,
8962        crate::protocol::NativeSessionCatalogSummary,
8963    ), NodeFailure> {
8964        let canonical_root = self.native_session_route_root(&route)?;
8965        let registered_workspace_roots = self.workspace_roots();
8966        let provider = route.provider.clone();
8967        let scope = route.scope;
8968        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8969            failure(
8970                NodeFailureCode::InvalidRequest,
8971                "native session catalog is not configured",
8972            )
8973        })?;
8974        let _permit = Arc::clone(&self.inspection_slots)
8975            .try_acquire_owned()
8976            .map_err(|_| {
8977                failure(
8978                    NodeFailureCode::BackendBusy,
8979                    "native session catalog capacity is busy",
8980                )
8981            })?;
8982        let page = run_native_session_catalog_operation(
8983            catalog,
8984            "native session catalog",
8985            move |catalog| {
8986                catalog.catalog_initial_for_scope(
8987                    &provider,
8988                    scope,
8989                    canonical_root.as_deref().map(Path::new),
8990                    &registered_workspace_roots,
8991                    limit,
8992                )
8993            },
8994        )
8995        .await?;
8996        let summary = crate::protocol::NativeSessionCatalogSummary {
8997            catalog_revision: page.revision,
8998            recent_cutoff_unix_ms: page.cutoff_unix_ms,
8999            recent_total_count: page.recent_total_count,
9000            older_total_count: page.older_total_count,
9001            recent_next_after_selection_id: page.next_after_selection_id,
9002            recent_has_more: page.remaining_count > 0,
9003        };
9004        Ok((self.project_native_session_entries(&route, page.entries), summary))
9005    }
9006
9007    async fn page_native_sessions(
9008        &self,
9009        route: NativeSessionCatalogRoute,
9010        window: crate::protocol::NativeSessionCatalogWindow,
9011        catalog_revision: u64,
9012        recent_cutoff_unix_ms: u64,
9013        after_selection_id: Option<String>,
9014        limit: u16,
9015    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9016        let canonical_root = self.native_session_route_root(&route)?;
9017        let registered_workspace_roots = self.workspace_roots();
9018        let provider = route.provider.clone();
9019        let scope = route.scope;
9020        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9021            failure(
9022                NodeFailureCode::InvalidRequest,
9023                "native session catalog is not configured",
9024            )
9025        })?;
9026        let _permit = Arc::clone(&self.inspection_slots)
9027            .try_acquire_owned()
9028            .map_err(|_| {
9029                failure(
9030                    NodeFailureCode::BackendBusy,
9031                    "native session catalog capacity is busy",
9032                )
9033            })?;
9034        let page = run_native_session_catalog_operation(
9035            catalog,
9036            "native session catalog page",
9037            move |catalog| {
9038                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9039                    &provider,
9040                    scope,
9041                    canonical_root.as_deref().map(Path::new),
9042                    &registered_workspace_roots,
9043                    window,
9044                    catalog_revision,
9045                    recent_cutoff_unix_ms,
9046                    after_selection_id.as_deref(),
9047                    limit,
9048                ))
9049            },
9050        )
9051        .await?;
9052        let page = page.map_err(|error| match error {
9053            NativeSessionCatalogError::StaleCatalog => failure(
9054                NodeFailureCode::StaleNativeSessionCatalog,
9055                "native session catalog revision or cursor is stale",
9056            ),
9057            _ => failure(
9058                NodeFailureCode::BackendOperationFailed,
9059                "native session catalog page failed",
9060            ),
9061        })?;
9062        let entries = self.project_native_session_entries(&route, page.entries);
9063        Ok(crate::protocol::NativeSessionCatalogPage {
9064            window: page.window,
9065            revision: page.revision,
9066            entries,
9067            next_after_selection_id: page.next_after_selection_id,
9068            remaining_count: page.remaining_count,
9069            has_more: page.remaining_count > 0,
9070        })
9071    }
9072
9073    async fn preview_native_session(
9074        &self,
9075        selection: NativeSessionSelection,
9076        message_limit: u16,
9077    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9078        selection
9079            .validate()
9080            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9081        let canonical_root = self.native_session_route_root(&selection.route)?;
9082        let registered_workspace_roots = self.workspace_roots();
9083        let provider = selection.route.provider.clone();
9084        let scope = selection.route.scope;
9085        let catalog_revision = selection.catalog_revision;
9086        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9087        let selection_id = selection.selection_id.clone();
9088        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9089            failure(
9090                NodeFailureCode::InvalidRequest,
9091                "native session preview is not configured",
9092            )
9093        })?;
9094        let _permit = Arc::clone(&self.inspection_slots)
9095            .try_acquire_owned()
9096            .map_err(|_| {
9097                failure(
9098                    NodeFailureCode::BackendBusy,
9099                    "native session preview capacity is busy",
9100                )
9101            })?;
9102        let preview = run_native_session_catalog_operation(
9103            catalog,
9104            "native session preview",
9105            move |catalog| {
9106                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9107                    &provider,
9108                    scope,
9109                    canonical_root.as_deref().map(Path::new),
9110                    &registered_workspace_roots,
9111                    catalog_revision,
9112                    recent_cutoff_unix_ms,
9113                    &selection_id,
9114                    message_limit,
9115                ))
9116            },
9117        )
9118        .await?;
9119        preview
9120            .map(Into::into)
9121            .map_err(native_session_preview_failure)
9122    }
9123
9124    async fn index_native_session(
9125        &self,
9126        selection: NativeSessionSelection,
9127        display_name: String,
9128    ) -> Result<ManagedSessionRecord, NodeFailure> {
9129        selection
9130            .validate()
9131            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9132        if selection.route.scope
9133            != gate4agent_types::NativeSessionCatalogScope::Workspace
9134        {
9135            return Err(failure(
9136                NodeFailureCode::WorkspaceRegistrationRequired,
9137                "register the external project as a workspace before indexing or resuming it",
9138            ));
9139        }
9140        let workspace_id = selection
9141            .route
9142            .workspace_id
9143            .clone()
9144            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9145        let canonical_root = self
9146            .native_session_route_root(&selection.route)?
9147            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9148        let registered_workspace_roots = self.workspace_roots();
9149        let provider = selection.route.provider.clone();
9150        let catalog_revision = selection.catalog_revision;
9151        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9152        let selection_id = selection.selection_id.clone();
9153        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9154            failure(
9155                NodeFailureCode::InvalidRequest,
9156                "native session index is not configured",
9157            )
9158        })?;
9159        let _permit = Arc::clone(&self.inspection_slots)
9160            .try_acquire_owned()
9161            .map_err(|_| {
9162                failure(
9163                    NodeFailureCode::BackendBusy,
9164                    "native session index capacity is busy",
9165                )
9166            })?;
9167        let resolved = run_native_session_catalog_operation(
9168            catalog,
9169            "native session index resolution",
9170            move |catalog| {
9171                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9172                    &provider,
9173                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9174                    Some(Path::new(&canonical_root)),
9175                    &registered_workspace_roots,
9176                    catalog_revision,
9177                    recent_cutoff_unix_ms,
9178                    &selection_id,
9179                ))
9180            },
9181        )
9182        .await?
9183        .map_err(native_session_preview_failure)?;
9184        self.index_provider_session_with_policy(
9185            workspace_id,
9186            selection.route.provider,
9187            resolved.identity,
9188            display_name,
9189            true,
9190        )
9191    }
9192
9193    async fn preview_native_session_by_session_id(
9194        &self,
9195        workspace_id: WorkspaceId,
9196        provider: AgentId,
9197        session_id: String,
9198        message_limit: u16,
9199    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9200        let canonical_root = self.workspace_root(&workspace_id)?;
9201        let registered_workspace_roots = self.workspace_roots();
9202        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9203            failure(
9204                NodeFailureCode::InvalidRequest,
9205                "native session preview is not configured",
9206            )
9207        })?;
9208        let _permit = Arc::clone(&self.inspection_slots)
9209            .try_acquire_owned()
9210            .map_err(|_| {
9211                failure(
9212                    NodeFailureCode::BackendBusy,
9213                    "native session preview capacity is busy",
9214                )
9215            })?;
9216        run_native_session_catalog_operation(
9217            catalog,
9218            "native session preview",
9219            move |catalog| {
9220                catalog.preview_session_id_for_workspace(
9221                    &provider,
9222                    Path::new(&canonical_root),
9223                    &registered_workspace_roots,
9224                    &session_id,
9225                    message_limit,
9226                )
9227            },
9228        )
9229        .await
9230    }
9231
9232    async fn register_workspace(
9233        &self,
9234        workspace_id: WorkspaceId,
9235        root: String,
9236    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9237        validate_workspace_request_root(&root)?;
9238        let canonicalize_workspace_id = workspace_id.clone();
9239        let workspace = tokio::task::spawn_blocking(move || {
9240            WorkspaceConfig::new(canonicalize_workspace_id, root)
9241        })
9242        .await
9243        .map_err(|error| {
9244            failure(
9245                NodeFailureCode::BackendOperationFailed,
9246                &format!("workspace canonicalization task failed: {error}"),
9247            )
9248        })?
9249        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9250        let transaction = self
9251            .state_transaction
9252            .lock()
9253            .unwrap_or_else(|poisoned| poisoned.into_inner());
9254        let mut workspaces = self
9255            .workspaces
9256            .write()
9257            .unwrap_or_else(|poisoned| poisoned.into_inner());
9258        if workspaces.contains_key(&workspace_id) {
9259            return Err(failure(
9260                NodeFailureCode::DuplicateWorkspaceId,
9261                "workspace ID is already registered",
9262            ));
9263        }
9264        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9265            platform::roots_equal(existing_root, workspace.canonical_root())
9266        }) {
9267            return Err(failure(
9268                NodeFailureCode::DuplicateWorkspaceRoot,
9269                &format!("workspace root is already registered as '{existing_id}'"),
9270            ));
9271        }
9272        {
9273            let records = self
9274                .session_records
9275                .lock()
9276                .unwrap_or_else(|poisoned| poisoned.into_inner());
9277            if let Some(record) = records.records.values().find(|record| {
9278                record.workspace_id == workspace_id
9279                    && !record
9280                        .canonical_root
9281                        .as_utf8()
9282                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9283            }) {
9284                return Err(failure(
9285                    NodeFailureCode::SessionRecordConflict,
9286                    &format!(
9287                        "workspace ID is retained by session record '{}' at another root",
9288                        record.record_id,
9289                    ),
9290                ));
9291            }
9292        }
9293        let snapshot = WorkspaceSnapshot {
9294            workspace_id: workspace_id.clone(),
9295            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9296            sessions: Vec::new(),
9297            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9298            managed_worktree_profiles: Some(WorktreeProfileInventory {
9299                profiles: Vec::new(),
9300            }),
9301        };
9302        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9303        drop(workspaces);
9304        let previous_records = {
9305            let mut records = self
9306                .session_records
9307                .lock()
9308                .unwrap_or_else(|poisoned| poisoned.into_inner());
9309            let previous = records.records.clone();
9310            for record in records.records.values_mut().filter(|record| {
9311                record.workspace_id == workspace_id
9312                    && record
9313                        .canonical_root
9314                        .as_utf8()
9315                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9316            }) {
9317                if record.active_session.is_none() {
9318                    record.state = detached_record_state(record);
9319                }
9320                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9321                    record.last_error = None;
9322                }
9323                record.updated_at_unix_ms = unix_time_ms();
9324            }
9325            previous
9326        };
9327        if let Err(error) = self.persist_state_locked() {
9328            self.workspaces
9329                .write()
9330                .unwrap_or_else(|poisoned| poisoned.into_inner())
9331                .remove(&workspace_id);
9332            self.session_records
9333                .lock()
9334                .unwrap_or_else(|poisoned| poisoned.into_inner())
9335                .records = previous_records;
9336            return Err(persistence_failure(error));
9337        }
9338        let restored = self
9339            .session_records
9340            .lock()
9341            .unwrap_or_else(|poisoned| poisoned.into_inner())
9342            .records
9343            .values()
9344            .filter(|record| record.workspace_id == workspace_id)
9345            .cloned()
9346            .collect::<Vec<_>>();
9347        drop(transaction);
9348        self.publish(NodeEvent::WorkspaceAdded {
9349            workspace: snapshot.clone(),
9350        });
9351        for record in restored {
9352            self.publish_record(record);
9353        }
9354        Ok(snapshot)
9355    }
9356
9357    async fn create_standalone_workspace(
9358        &self,
9359        workspace_id: WorkspaceId,
9360        root: String,
9361        initial_branch: Option<String>,
9362    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9363        validate_workspace_request_root(&root)?;
9364        validate_git_revision("initial branch", initial_branch.as_deref())?;
9365        let preflight_workspace_id = workspace_id.clone();
9366        let preflight_root = root.clone();
9367        let canonical_candidate = tokio::task::spawn_blocking(move || {
9368            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9369        })
9370        .await
9371        .map_err(|_| failure(
9372            NodeFailureCode::BackendOperationFailed,
9373            "standalone workspace root preflight task failed",
9374        ))?
9375        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9376        {
9377            let workspaces = self
9378                .workspaces
9379                .read()
9380                .unwrap_or_else(|poisoned| poisoned.into_inner());
9381            if workspaces.contains_key(&workspace_id) {
9382                return Err(failure(
9383                    NodeFailureCode::DuplicateWorkspaceId,
9384                    "workspace ID is already registered",
9385                ));
9386            }
9387            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9388                platform::roots_equal(existing_root, &canonical_candidate)
9389            }) {
9390                return Err(failure(
9391                    NodeFailureCode::DuplicateWorkspaceRoot,
9392                    &format!("workspace root is already registered as '{existing_id}'"),
9393                ));
9394            }
9395        }
9396        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9397            .await
9398            .map_err(standalone_workspace_failure)?;
9399        prepared
9400            .verify_for_registration()
9401            .await
9402            .map_err(standalone_workspace_failure)?;
9403        match self
9404            .register_workspace(workspace_id, prepared.root().to_owned())
9405            .await
9406        {
9407            Ok(workspace) => Ok(workspace),
9408            Err(registration_error) => {
9409                if prepared.compensate_registration_failure().await {
9410                    Err(registration_error)
9411                } else {
9412                    Err(failure(
9413                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9414                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9415                    ))
9416                }
9417            }
9418        }
9419    }
9420
9421    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9422        let bindings = self
9423            .session_bindings
9424            .lock()
9425            .unwrap_or_else(|poisoned| poisoned.into_inner());
9426        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9427            return Err(failure(
9428                NodeFailureCode::WorkspaceBusy,
9429                "workspace retains a bound session; remove every session before unregistering it",
9430            ));
9431        }
9432        drop(bindings);
9433        let transaction = self
9434            .state_transaction
9435            .lock()
9436            .unwrap_or_else(|poisoned| poisoned.into_inner());
9437        let mut workspaces = self
9438            .workspaces
9439            .write()
9440            .unwrap_or_else(|poisoned| poisoned.into_inner());
9441        if !workspaces.contains_key(workspace_id) {
9442            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9443        }
9444        if workspaces.len() == 1 {
9445            return Err(failure(
9446                NodeFailureCode::LastWorkspace,
9447                "node must retain at least one workspace",
9448            ));
9449        }
9450        let removed_root = workspaces
9451            .remove(workspace_id)
9452            .expect("workspace presence was validated");
9453        drop(workspaces);
9454        let previous_records = {
9455            let mut records = self
9456                .session_records
9457                .lock()
9458                .unwrap_or_else(|poisoned| poisoned.into_inner());
9459            let previous = records.records.clone();
9460            for record in records
9461                .records
9462                .values_mut()
9463                .filter(|record| &record.workspace_id == workspace_id)
9464            {
9465                record.active_session = None;
9466                record.state = ManagedSessionState::Unavailable;
9467                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9468                record.updated_at_unix_ms = unix_time_ms();
9469            }
9470            previous
9471        };
9472        if let Err(error) = self.persist_state_locked() {
9473            self.workspaces
9474                .write()
9475                .unwrap_or_else(|poisoned| poisoned.into_inner())
9476                .insert(workspace_id.clone(), removed_root);
9477            self.session_records
9478                .lock()
9479                .unwrap_or_else(|poisoned| poisoned.into_inner())
9480                .records = previous_records;
9481            return Err(persistence_failure(error));
9482        }
9483        let unavailable = self
9484            .session_records
9485            .lock()
9486            .unwrap_or_else(|poisoned| poisoned.into_inner())
9487            .records
9488            .values()
9489            .filter(|record| &record.workspace_id == workspace_id)
9490            .cloned()
9491            .collect::<Vec<_>>();
9492        drop(transaction);
9493        self.publish(NodeEvent::WorkspaceRemoved {
9494            workspace_id: workspace_id.clone(),
9495        });
9496        for record in unavailable {
9497            self.publish_record(record);
9498        }
9499        Ok(())
9500    }
9501
9502    async fn create_worktree(
9503        &self,
9504        source_workspace_id: WorkspaceId,
9505        workspace_id: WorkspaceId,
9506        target_root: String,
9507        branch: String,
9508        base: Option<String>,
9509    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9510        self.require_worktree_service(&source_workspace_id)?;
9511        validate_workspace_request_root(&target_root)?;
9512        validate_git_revision("worktree base", base.as_deref())?;
9513        let source_root = self.workspace_root(&source_workspace_id)?;
9514        {
9515            let workspaces = self
9516                .workspaces
9517                .read()
9518                .unwrap_or_else(|poisoned| poisoned.into_inner());
9519            if workspaces.contains_key(&workspace_id) {
9520                return Err(failure(
9521                    NodeFailureCode::DuplicateWorkspaceId,
9522                    "workspace ID is already registered",
9523                ));
9524            }
9525            if let Some((existing_id, _)) = workspaces
9526                .iter()
9527                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9528            {
9529                return Err(failure(
9530                    NodeFailureCode::DuplicateWorkspaceRoot,
9531                    &format!("workspace root is already registered as '{existing_id}'"),
9532                ));
9533            }
9534        }
9535        let mut worktree = create_git_worktree(
9536            &source_root,
9537            &target_root,
9538            &branch,
9539            base.as_deref(),
9540        )
9541        .await
9542        .map_err(git_worktree_failure)?;
9543        let workspace = match self
9544            .register_workspace(workspace_id, worktree.path.clone())
9545            .await
9546        {
9547            Ok(workspace) => workspace,
9548            Err(registration_error) => {
9549                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9550                    return Err(failure(
9551                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9552                        "worktree registration failed and compensation requires recovery",
9553                    ));
9554                }
9555                return Err(failure(
9556                    registration_error.code,
9557                    &registration_error.message,
9558                ));
9559            }
9560        };
9561        worktree.workspace_id = Some(workspace.workspace_id.clone());
9562        Ok((protocol_worktree(worktree), workspace))
9563    }
9564
9565    async fn remove_worktree(
9566        &self,
9567        source_workspace_id: WorkspaceId,
9568        target_root: String,
9569    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9570        self.require_worktree_service(&source_workspace_id)?;
9571        validate_workspace_request_root(&target_root)?;
9572        let source_root = self.workspace_root(&source_workspace_id)?;
9573        let target_root = normalize_worktree_removal_target(&target_root)
9574            .map_err(git_worktree_failure)?;
9575        let target = list_git_worktrees(&source_root)
9576            .await
9577            .map_err(git_worktree_failure)?
9578            .into_iter()
9579            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9580            .ok_or_else(|| {
9581                failure(
9582                    NodeFailureCode::WorktreeProtected,
9583                    "refusing to remove a path that is not in Git's worktree listing",
9584                )
9585            })?;
9586        let registered_workspace_id = {
9587            let workspaces = self
9588                .workspaces
9589                .read()
9590                .unwrap_or_else(|poisoned| poisoned.into_inner());
9591            let matched = workspaces
9592                .iter()
9593                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9594                .map(|(workspace_id, _)| workspace_id.clone());
9595            if matched.is_some() && workspaces.len() == 1 {
9596                return Err(failure(
9597                    NodeFailureCode::LastWorkspace,
9598                    "node must retain at least one workspace",
9599                ));
9600            }
9601            matched
9602        };
9603        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9604            let bindings = self
9605                .session_bindings
9606                .lock()
9607                .unwrap_or_else(|poisoned| poisoned.into_inner());
9608            if bindings
9609                .values()
9610                .any(|binding| &binding.workspace_id == workspace_id)
9611            {
9612                return Err(failure(
9613                    NodeFailureCode::WorkspaceBusy,
9614                    "worktree workspace retains a bound session; remove every session first",
9615                ));
9616            }
9617        }
9618        remove_git_worktree(&source_root, &target.path)
9619            .await
9620            .map_err(git_worktree_failure)?;
9621        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9622            self.unregister_workspace(workspace_id)
9623                .map_err(|error| failure(error.code, &error.message))?;
9624        }
9625        Ok(registered_workspace_id)
9626    }
9627
9628    async fn cleanup_managed_worktree(
9629        &self,
9630        lease_id: &ManagedWorktreeLeaseId,
9631        explicit: bool,
9632    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9633        let lease = self.managed_worktrees.lock()
9634            .unwrap_or_else(|poisoned| poisoned.into_inner())
9635            .get(lease_id)
9636            .cloned()
9637            .ok_or_else(|| failure(
9638                NodeFailureCode::UnknownManagedWorktreeLease,
9639                "managed worktree lease does not exist",
9640            ))?;
9641        if lease.state == ManagedWorktreeLeaseState::Removed {
9642            return Ok(lease.snapshot());
9643        }
9644        if lease.has_holders() {
9645            return Err(failure(
9646                NodeFailureCode::ManagedWorktreeBusy,
9647                "managed worktree lease still has a session or durable-record holder",
9648            ));
9649        }
9650        if self
9651            .materializations
9652            .lock()
9653            .unwrap_or_else(|poisoned| poisoned.into_inner())
9654            .values()
9655            .any(|record| record.managed_lease_id() == Some(lease_id))
9656        {
9657            return Err(failure(
9658                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659                "managed worktree cleanup is blocked by session environment ownership",
9660            ));
9661        }
9662        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9663            let snapshot = {
9664                let mut registry = self.managed_worktrees.lock()
9665                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9666                let current = registry.get_mut(lease_id)
9667                    .expect("managed worktree lease remains present");
9668                current.state = ManagedWorktreeLeaseState::Retained;
9669                current.cleanup_failure = None;
9670                current.updated_at_unix_ms = unix_time_ms();
9671                current.snapshot()
9672            };
9673            if self.persist_state().is_err() {
9674                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9675            }
9676            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9677            return Ok(snapshot);
9678        }
9679        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9680        if profile.revision() != &lease.profile_revision {
9681            self.mark_managed_recovery_required(
9682                lease_id,
9683                ManagedWorktreeCleanupFailure::OwnershipConflict,
9684            );
9685            return Err(failure(
9686                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9687                "managed worktree profile revision changed",
9688            ));
9689        }
9690        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9691            self.mark_managed_recovery_required(
9692                lease_id,
9693                ManagedWorktreeCleanupFailure::OwnershipConflict,
9694            );
9695            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9696        })?;
9697        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9698        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9699            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9700            managed_git_worktree_failure(error)
9701        })?;
9702        let target = listed.iter()
9703            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9704        if let Some(target) = target {
9705            if target.is_main || target.is_bare || !exact_owned_worktree(
9706                &lease,
9707                &target.path,
9708                target.branch.as_deref(),
9709                &target.head,
9710            ) {
9711                self.mark_managed_recovery_required(
9712                    lease_id,
9713                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9714                );
9715                return Err(failure(
9716                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9717                    "Git worktree identity no longer matches the managed lease",
9718                ));
9719            }
9720            if target.prunable {
9721                self.mark_managed_cleanup_blocked(
9722                    lease_id,
9723                    ManagedWorktreeCleanupFailure::Prunable,
9724                );
9725                return Err(failure(
9726                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9727                    "managed worktree is prunable and requires recovery",
9728                ));
9729            }
9730            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9731                let failure_kind = match error.kind {
9732                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9733                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9734                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9735                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9736                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9737                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9738                };
9739                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9740                    self.mark_managed_recovery_required(lease_id, failure_kind);
9741                } else {
9742                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9743                }
9744                return Err(managed_git_worktree_failure(error));
9745            }
9746        } else {
9747            match std::fs::symlink_metadata(&lease.target_root) {
9748                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9749                Ok(_) => {
9750                    self.mark_managed_recovery_required(
9751                        lease_id,
9752                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9753                    );
9754                    return Err(failure(
9755                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9756                        "managed target exists but is not owned by Git",
9757                    ));
9758                }
9759                Err(_) => {
9760                    self.mark_managed_cleanup_blocked(
9761                        lease_id,
9762                        ManagedWorktreeCleanupFailure::Backend,
9763                    );
9764                    return Err(failure(
9765                        NodeFailureCode::BackendOperationFailed,
9766                        "managed target identity could not be inspected",
9767                    ));
9768                }
9769            }
9770        }
9771        let registered = self.workspaces.read()
9772            .unwrap_or_else(|poisoned| poisoned.into_inner())
9773            .contains_key(&lease.workspace_id);
9774        if registered {
9775            self.unregister_workspace(&lease.workspace_id)?;
9776        }
9777        let removed = {
9778            self.managed_worktrees.lock()
9779                .unwrap_or_else(|poisoned| poisoned.into_inner())
9780                .tombstone(lease_id, unix_time_ms())
9781                .expect("managed worktree lease remains present during cleanup")
9782        };
9783        self.persist_state().map_err(persistence_failure)?;
9784        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9785        Ok(removed.snapshot())
9786    }
9787
9788    fn mark_managed_cleanup_blocked(
9789        &self,
9790        lease_id: &ManagedWorktreeLeaseId,
9791        failure_kind: ManagedWorktreeCleanupFailure,
9792    ) {
9793        let snapshot = {
9794            let mut registry = self.managed_worktrees.lock()
9795                .unwrap_or_else(|poisoned| poisoned.into_inner());
9796            let Some(lease) = registry.get_mut(lease_id) else { return };
9797            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9798            lease.cleanup_failure = Some(failure_kind);
9799            lease.updated_at_unix_ms = unix_time_ms();
9800            lease.snapshot()
9801        };
9802        let _ = self.persist_state();
9803        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9804    }
9805
9806    fn require_worktree_service(
9807        &self,
9808        source_workspace_id: &WorkspaceId,
9809    ) -> Result<(), NodeFailure> {
9810        if self.managed_worktrees.lock()
9811            .unwrap_or_else(|poisoned| poisoned.into_inner())
9812            .lease_for_workspace(source_workspace_id)
9813            .is_some()
9814        {
9815            return Err(failure(
9816                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9817                "managed worktree targets are internal-only workspaces",
9818            ));
9819        }
9820        match self
9821            .worktree_service_modes
9822            .get(source_workspace_id)
9823            .copied()
9824            .unwrap_or(WorktreeServiceMode::Manual)
9825        {
9826            WorktreeServiceMode::Manual => Ok(()),
9827            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9828                NodeFailureCode::UnsupportedSpawnCapability,
9829                "Git worktree service mode is unavailable in this node build",
9830            )),
9831        }
9832    }
9833
9834    fn managed_reservation_conflict(
9835        &self,
9836        workspace_id: Option<&WorkspaceId>,
9837        root: Option<&str>,
9838    ) -> bool {
9839        self.managed_worktrees.lock()
9840            .unwrap_or_else(|poisoned| poisoned.into_inner())
9841            .active_records()
9842            .any(|lease| {
9843                workspace_id.is_some_and(|workspace_id| {
9844                    workspace_id == &lease.source_workspace_id
9845                        || workspace_id == &lease.workspace_id
9846                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9847            })
9848    }
9849
9850    fn reject_managed_reservation(
9851        &self,
9852        workspace_id: Option<&WorkspaceId>,
9853        root: Option<&str>,
9854    ) -> Result<(), NodeFailure> {
9855        if self.managed_reservation_conflict(workspace_id, root) {
9856            Err(failure(
9857                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9858                "managed worktree ownership may only be mutated through its lease cleanup",
9859            ))
9860        } else {
9861            Ok(())
9862        }
9863    }
9864
9865    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9866        self.validate_address(address)?;
9867        self.workspace_root(&address.workspace_id)
9868    }
9869
9870    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9871    /// `require_policy`'s own doc on those two requirements), so they keep
9872    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9873    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9874    /// `false` on the `require_policy` call below for them because a PTY
9875    /// resume never needs those facts, not because this method cannot look
9876    /// them up.
9877    ///
9878    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9879    /// the SAME `binding.runtime_policy` every other requirement reads here
9880    /// (one computed policy, read by every gate). That policy is no longer
9881    /// PTY-terminal-verification-shaped for an ACP session --
9882    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9883    /// `provider_runtime::policy_for_transport` -- so consulting it for
9884    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9885    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9886    /// even though a prompt is an ACP session's only interface). Pipe keeps
9887    /// reading the same PTY-probe-derived value it always has --
9888    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9889    /// its own doc for why).
9890    ///
9891    /// What this method still adds on top, for ACP/Pipe only, is a
9892    /// turn-in-flight PRECONDITION ahead of that policy check: a session
9893    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
9894    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
9895    /// read off the same live `SessionSnapshot` state `require_acp_session`
9896    /// already reads for the ACP control verbs -- `TurnStarted` observed
9897    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
9898    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
9899    /// its fate in vendor-specific behaviour this node cannot observe (one
9900    /// agent may queue it, another drop it, another interleave it), so this
9901    /// refuses it outright rather than gambling on that -- a rule with no
9902    /// PTY analogue, which is why PTY does not take this extra precondition.
9903    fn require_session_runtime_policy(
9904        &self,
9905        address: &SessionAddress,
9906        requirement: ProviderRuntimeRequirement,
9907    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9908        let runtime_policy = {
9909            let bindings = self
9910                .session_bindings
9911                .lock()
9912                .unwrap_or_else(|poisoned| poisoned.into_inner());
9913            let binding = bindings
9914                .get(&address.session.instance_id)
9915                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9916            if binding.workspace_id != address.workspace_id {
9917                return Err(failure(
9918                    NodeFailureCode::SessionWorkspaceMismatch,
9919                    "session belongs to another workspace",
9920                ));
9921            }
9922            if binding.generation != address.session.generation {
9923                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9924            }
9925            binding.runtime_policy
9926        };
9927        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9928            let snapshot = self.internal_session_snapshot(address)?;
9929            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9930                && snapshot.provider.lead_activity != ProviderActivity::Idle
9931            {
9932                return Err(failure(
9933                    NodeFailureCode::TurnInFlight,
9934                    "session already has a turn in flight; wait for it to complete before sending another prompt",
9935                ));
9936            }
9937        }
9938        require_policy(runtime_policy, false, false, requirement)
9939            .map(|()| runtime_policy)
9940            .map_err(|_| failure(
9941                NodeFailureCode::UnsupportedCapability,
9942                "session runtime policy does not admit this semantic operation",
9943            ))
9944    }
9945
9946    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9947        let mut bindings = self
9948            .session_bindings
9949            .lock()
9950            .unwrap_or_else(|poisoned| poisoned.into_inner());
9951        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9952            binding.workspace_id == address.workspace_id
9953                && binding.generation == address.session.generation
9954        }) {
9955            let removed = bindings.remove(&address.session.instance_id);
9956            self.clear_terminal_frame_watermark(address);
9957            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
9958            return removed;
9959        }
9960        None
9961    }
9962
9963    fn cleanup_session_owned_materialization(
9964        &self,
9965        address: &SessionAddress,
9966        binding: &SessionBinding,
9967    ) -> Result<(), NodeFailure> {
9968        let Some(id) = binding.materialization_id.as_ref() else {
9969            return Ok(());
9970        };
9971        let owner = self
9972            .materializations
9973            .lock()
9974            .unwrap_or_else(|poisoned| poisoned.into_inner())
9975            .get(id)
9976            .map(|record| record.owner().clone());
9977        match owner {
9978            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9979            Some(MaterializationOwner::Session {
9980                incarnation_id,
9981                instance_id,
9982                generation,
9983            }) if incarnation_id == self.incarnation_id
9984                && instance_id == address.session.instance_id
9985                && generation == address.session.generation =>
9986            {
9987                self.cleanup_materialization(id)
9988            }
9989            Some(MaterializationOwner::Session { .. }) => Err(failure(
9990                NodeFailureCode::BackendOperationFailed,
9991                "session environment ownership requires recovery",
9992            )),
9993        }
9994    }
9995
9996    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9997        let mut watermarks = self
9998            .terminal_frame_watermarks
9999            .lock()
10000            .unwrap_or_else(|poisoned| poisoned.into_inner());
10001        if watermarks
10002            .get(&address.session.instance_id)
10003            .is_some_and(|(current, _)| current == address)
10004        {
10005            watermarks.remove(&address.session.instance_id);
10006        }
10007    }
10008
10009    #[cfg(test)]
10010    fn address_for(
10011        &self,
10012        instance_id: AgentInstanceId,
10013        generation: SessionGeneration,
10014    ) -> Option<SessionAddress> {
10015        let binding = self.session_bindings
10016            .lock()
10017            .unwrap_or_else(|poisoned| poisoned.into_inner())
10018            .get(&instance_id)
10019            .cloned()?;
10020        (binding.generation == generation).then_some(SessionAddress {
10021            workspace_id: binding.workspace_id,
10022            session: SessionKey { instance_id, generation },
10023        })
10024    }
10025
10026    fn arm_resume(
10027        &self,
10028        address: &SessionAddress,
10029        command_id: CommandId,
10030        runtime_policy: ProviderRuntimePolicy,
10031    ) -> Result<(), NodeFailure> {
10032        let mut bindings = self
10033            .session_bindings
10034            .lock()
10035            .unwrap_or_else(|poisoned| poisoned.into_inner());
10036        let binding = bindings
10037            .get_mut(&address.session.instance_id)
10038            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10039        if binding.workspace_id != address.workspace_id {
10040            return Err(failure(
10041                NodeFailureCode::SessionWorkspaceMismatch,
10042                "session belongs to another workspace",
10043            ));
10044        }
10045        if binding.generation != address.session.generation {
10046            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10047        }
10048        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10049        Ok(())
10050    }
10051
10052    fn clear_armed_resume(&self, address: &SessionAddress) {
10053        let mut bindings = self
10054            .session_bindings
10055            .lock()
10056            .unwrap_or_else(|poisoned| poisoned.into_inner());
10057        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10058            if binding.workspace_id == address.workspace_id
10059                && binding.generation == address.session.generation
10060            {
10061                binding.pending_resume = None;
10062            }
10063        }
10064    }
10065
10066    fn publish_control(&self, event: ControlEvent) {
10067        let revokes_harness_mcp = matches!(
10068            &event.event,
10069            ControlEventKind::Exited { .. }
10070                | ControlEventKind::Failed { .. }
10071                | ControlEventKind::Removed
10072        );
10073        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10074            let mut bindings = self
10075                .session_bindings
10076                .lock()
10077                .unwrap_or_else(|poisoned| poisoned.into_inner());
10078            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10079                return;
10080            };
10081            let previous_address = SessionAddress {
10082                workspace_id: binding.workspace_id.clone(),
10083                session: SessionKey {
10084                    instance_id: event.instance_id,
10085                    generation: binding.generation,
10086                },
10087            };
10088            let mut resume_command_rejected = false;
10089            if binding.generation == event.generation {
10090                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10091                    let rejected_resume = matches!(
10092                        &event.event,
10093                        ControlEventKind::CommandRejected { .. }
10094                    ) && event.command_id == Some(pending_command_id);
10095                    let resume_failed = matches!(
10096                        &event.event,
10097                        ControlEventKind::ResumeDenied { .. }
10098                            | ControlEventKind::ResumeFailed { .. }
10099                    );
10100                    if pending_generation == binding.generation
10101                        && (rejected_resume || resume_failed)
10102                    {
10103                        resume_command_rejected = rejected_resume;
10104                        binding.pending_resume = None;
10105                    }
10106                }
10107            } else {
10108                let expected = binding.generation.0.checked_add(1);
10109                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10110                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10111                    generation == binding.generation
10112                })
10113                    || expected != Some(event.generation.0)
10114                    || !authorized
10115                {
10116                    return;
10117                }
10118                let (_, _, runtime_policy) = binding
10119                    .pending_resume
10120                    .expect("authorized resume retains its pending runtime policy");
10121                binding.generation = event.generation;
10122                binding.runtime_policy = runtime_policy;
10123                binding.pending_resume = None;
10124            }
10125            (
10126                SessionAddress {
10127                    workspace_id: binding.workspace_id.clone(),
10128                    session: SessionKey {
10129                        instance_id: event.instance_id,
10130                        generation: event.generation,
10131                    },
10132                },
10133                (previous_address.session.generation != event.generation)
10134                    .then_some(previous_address),
10135                binding.record_id.clone(),
10136                binding.runtime_policy.provider_session_identity,
10137                resume_command_rejected,
10138            )
10139        };
10140        if let Some(previous_address) = previous_address.as_ref() {
10141            self.clear_terminal_frame_watermark(previous_address);
10142        }
10143        if let Some(record_id) = record_id {
10144            self.reconcile_managed_record(
10145                &record_id,
10146                &address,
10147                &event,
10148                identity_admitted,
10149                resume_command_rejected,
10150            );
10151        }
10152        // Every state change a session's process lifecycle can reach gets a
10153        // line here: this is the single chokepoint every `ControlEventKind`
10154        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10155        // came from a fresh spawn or a resume. Per-message provider chatter
10156        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10157        // matched here -- that is exactly the per-frame noise this crate
10158        // family's level discipline forbids at INFO.
10159        match &event.event {
10160            ControlEventKind::Running { process_id } => {
10161                tracing::info!(
10162                    node_id = %self.node_id,
10163                    workspace_id = %address.workspace_id,
10164                    session = ?address.session,
10165                    pid = ?process_id,
10166                    "session process is running",
10167                );
10168            }
10169            ControlEventKind::Exited { exit_code, forced } => {
10170                tracing::info!(
10171                    node_id = %self.node_id,
10172                    workspace_id = %address.workspace_id,
10173                    session = ?address.session,
10174                    exit_code = ?exit_code,
10175                    forced = forced,
10176                    "session process exited",
10177                );
10178            }
10179            ControlEventKind::Failed { message } => {
10180                tracing::warn!(
10181                    node_id = %self.node_id,
10182                    workspace_id = %address.workspace_id,
10183                    session = ?address.session,
10184                    cause = %message,
10185                    "session failed",
10186                );
10187            }
10188            ControlEventKind::CommandRejected { message } => {
10189                tracing::warn!(
10190                    node_id = %self.node_id,
10191                    workspace_id = %address.workspace_id,
10192                    session = ?address.session,
10193                    cause = %message,
10194                    "session command rejected",
10195                );
10196            }
10197            ControlEventKind::Removed => {
10198                tracing::info!(
10199                    node_id = %self.node_id,
10200                    workspace_id = %address.workspace_id,
10201                    session = ?address.session,
10202                    "session removed",
10203                );
10204            }
10205            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10206                tracing::info!(
10207                    node_id = %self.node_id,
10208                    workspace_id = %address.workspace_id,
10209                    session = ?address.session,
10210                    provider_session = ?provider_session,
10211                    "session resume authorized",
10212                );
10213            }
10214            ControlEventKind::Resumed { session: provider_session, process_id } => {
10215                tracing::info!(
10216                    node_id = %self.node_id,
10217                    workspace_id = %address.workspace_id,
10218                    session = ?address.session,
10219                    provider_session = ?provider_session,
10220                    pid = ?process_id,
10221                    "session resumed",
10222                );
10223            }
10224            ControlEventKind::ResumeDenied { reason } => {
10225                tracing::warn!(
10226                    node_id = %self.node_id,
10227                    workspace_id = %address.workspace_id,
10228                    session = ?address.session,
10229                    cause = %reason,
10230                    "session resume denied",
10231                );
10232            }
10233            ControlEventKind::ResumeFailed { message } => {
10234                tracing::warn!(
10235                    node_id = %self.node_id,
10236                    workspace_id = %address.workspace_id,
10237                    session = ?address.session,
10238                    cause = %message,
10239                    "session resume failed",
10240                );
10241            }
10242            _ => {}
10243        }
10244        let stream_chunk = agent_stream_chunk(&event);
10245        self.publish(NodeEvent::Control {
10246            address: address.clone(),
10247            event,
10248        });
10249        if let Some(chunk) = stream_chunk {
10250            self.publish_agent_stream_chunk(address.clone(), chunk);
10251        }
10252        if revokes_harness_mcp {
10253            SessionLifecycleHook::on_session_ended(self, &address);
10254        }
10255    }
10256
10257    fn reconcile_managed_record(
10258        &self,
10259        record_id: &SessionRecordId,
10260        address: &SessionAddress,
10261        event: &ControlEvent,
10262        identity_admitted: bool,
10263        resume_command_rejected: bool,
10264    ) {
10265        let _transaction = self
10266            .state_transaction
10267            .lock()
10268            .unwrap_or_else(|poisoned| poisoned.into_inner());
10269        let observed_identity = match &event.event {
10270            ControlEventKind::ProviderEvent {
10271                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10272                ..
10273            } if identity_admitted => Some(identity.clone()),
10274            _ => None,
10275        };
10276        let identity_was_observed = observed_identity.is_some();
10277        let replacement_id = observed_identity
10278            .as_ref()
10279            .and_then(|_| self.allocate_record_id().ok());
10280        let mut upserts = Vec::new();
10281        let mut removals = Vec::new();
10282        let mut rebind = None;
10283        let original_records;
10284        let original_materializations = self
10285            .materializations
10286            .lock()
10287            .unwrap_or_else(|poisoned| poisoned.into_inner())
10288            .clone();
10289        {
10290            let mut records = self
10291                .session_records
10292                .lock()
10293                .unwrap_or_else(|poisoned| poisoned.into_inner());
10294            original_records = records.records.clone();
10295            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10296                return;
10297            };
10298            if let Some(identity) = observed_identity {
10299                let existing_id = records
10300                    .records
10301                    .values()
10302                    .find(|record| {
10303                        record.record_id != *record_id
10304                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10305                                session_registry::same_provider_session(
10306                                    &record.provider,
10307                                    record_identity,
10308                                    &current_snapshot.provider,
10309                                    &identity,
10310                                )
10311                            })
10312                    })
10313                    .map(|record| record.record_id.clone());
10314                if let Some(existing_id) = existing_id {
10315                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10316                        record.mode == current_snapshot.mode
10317                            && record.workspace_id == current_snapshot.workspace_id
10318                            && record.environment_profile == current_snapshot.environment_profile
10319                            && record.bundle == current_snapshot.bundle
10320                            && record.context_id == current_snapshot.context_id
10321                            && record.context == current_snapshot.context
10322                            && record
10323                                .canonical_root
10324                                .as_utf8()
10325                                .zip(current_snapshot.canonical_root.as_utf8())
10326                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10327                    });
10328                    if !scope_matches {
10329                        if let Some(current) = records.records.get_mut(record_id) {
10330                            current.state = ManagedSessionState::Unavailable;
10331                            current.last_error =
10332                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10333                            current.updated_at_unix_ms = unix_time_ms();
10334                            upserts.push(current.clone());
10335                        }
10336                    } else {
10337                        let existing_is_active = records
10338                            .records
10339                            .get(&existing_id)
10340                            .is_some_and(|record| record.active_session.is_some());
10341                        if existing_is_active {
10342                            if let Some(current) = records.records.get_mut(record_id) {
10343                                current.state = ManagedSessionState::Unavailable;
10344                                current.last_error =
10345                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10346                                current.updated_at_unix_ms = unix_time_ms();
10347                                upserts.push(current.clone());
10348                            }
10349                        } else {
10350                            if current_snapshot.provider_session.is_some() {
10351                                if let Some(current) = records.records.get_mut(record_id) {
10352                                    current.active_session = None;
10353                                    current.state = ManagedSessionState::Dormant;
10354                                    current.updated_at_unix_ms = unix_time_ms();
10355                                    upserts.push(current.clone());
10356                                }
10357                            } else {
10358                                records.records.remove(record_id);
10359                                removals.push(record_id.clone());
10360                            }
10361                            if let Some(existing) = records.records.get_mut(&existing_id) {
10362                                existing.provider_session = Some(identity.clone());
10363                                existing.active_session = Some(address.clone());
10364                                existing.state = ManagedSessionState::Live;
10365                                existing.updated_at_unix_ms = unix_time_ms();
10366                                existing.last_error = None;
10367                                upserts.push(existing.clone());
10368                            }
10369                            rebind = Some(existing_id);
10370                        }
10371                    }
10372                } else if current_snapshot
10373                    .provider_session
10374                    .as_ref()
10375                    .is_some_and(|current| {
10376                        !session_registry::same_provider_session(
10377                            &current_snapshot.provider,
10378                            current,
10379                            &current_snapshot.provider,
10380                            &identity,
10381                        )
10382                    })
10383                {
10384                    if let Some(current) = records.records.get_mut(record_id) {
10385                        current.active_session = None;
10386                        if current.bundle.is_some() || current.context.is_some() {
10387                            let had_bundle = current.bundle.is_some();
10388                            current.provider_session = None;
10389                            current.bundle = None;
10390                            current.context_id = None;
10391                            current.context = None;
10392                            current.state = ManagedSessionState::Unavailable;
10393                            current.last_error = Some(if had_bundle {
10394                                "bundle-unavailable".to_owned()
10395                            } else {
10396                                "context-unavailable".to_owned()
10397                            });
10398                        } else {
10399                            current.state = ManagedSessionState::Dormant;
10400                        }
10401                        current.updated_at_unix_ms = unix_time_ms();
10402                        upserts.push(current.clone());
10403                    }
10404                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10405                        if let Some(current) = records.records.get_mut(record_id) {
10406                            current.state = ManagedSessionState::Unavailable;
10407                            current.last_error =
10408                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10409                            current.updated_at_unix_ms = unix_time_ms();
10410                            upserts.push(current.clone());
10411                        }
10412                    } else if let Some(new_record_id) = replacement_id {
10413                        let now = unix_time_ms();
10414                        let replacement = ManagedSessionRecord {
10415                            record_id: new_record_id.clone(),
10416                            display_name: format!(
10417                                "{} #{}",
10418                                current_snapshot.provider.as_str(),
10419                                records
10420                                    .records
10421                                    .values()
10422                                    .filter(|record| {
10423                                        record.provider == current_snapshot.provider
10424                                    })
10425                                    .count()
10426                                    .saturating_add(1),
10427                            ),
10428                            provider: current_snapshot.provider.clone(),
10429                            mode: current_snapshot.mode,
10430                            state: ManagedSessionState::Live,
10431                            workspace_id: current_snapshot.workspace_id,
10432                            canonical_root: current_snapshot.canonical_root,
10433                            provider_session: Some(identity),
10434                            active_session: Some(address.clone()),
10435                            environment_profile: current_snapshot.environment_profile,
10436                            bundle: current_snapshot.bundle,
10437                            context_id: current_snapshot.context_id,
10438                            context: current_snapshot.context,
10439                            // This is a fresh record_id representing the session under its
10440                            // newly-observed identity; it has not yet produced any clean
10441                            // exit of its own, so it starts with no exported pack (the old
10442                            // record, still addressable under its own record_id, keeps
10443                            // whatever exported_context it already had).
10444                            exported_context: None,
10445                            task_binding: current_snapshot.task_binding,
10446                            created_at_unix_ms: now,
10447                            updated_at_unix_ms: now,
10448                            last_error: None,
10449                        };
10450                        records
10451                            .records
10452                            .insert(new_record_id.clone(), replacement.clone());
10453                        upserts.push(replacement);
10454                        rebind = Some(new_record_id);
10455                    } else if let Some(current) = records.records.get_mut(record_id) {
10456                        current.state = ManagedSessionState::Unavailable;
10457                        current.last_error =
10458                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10459                        upserts.push(current.clone());
10460                    }
10461                } else if let Some(current) = records.records.get_mut(record_id) {
10462                    current.provider_session = Some(identity);
10463                    current.active_session = Some(address.clone());
10464                    current.state = ManagedSessionState::Live;
10465                    current.updated_at_unix_ms = unix_time_ms();
10466                    current.last_error = None;
10467                    upserts.push(current.clone());
10468                }
10469            } else if let Some(current) = records.records.get_mut(record_id) {
10470                if resume_command_rejected {
10471                    current.active_session = None;
10472                    current.state = detached_record_state(current);
10473                    current.updated_at_unix_ms = unix_time_ms();
10474                    current.last_error =
10475                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10476                    upserts.push(current.clone());
10477                } else {
10478                    match &event.event {
10479                    ControlEventKind::Running { .. }
10480                    | ControlEventKind::ResumeAuthorized { .. }
10481                    | ControlEventKind::Resumed { .. } => {
10482                        current.active_session = Some(address.clone());
10483                        current.state = active_record_state(
10484                            identity_admitted,
10485                            current.provider_session.is_some(),
10486                        );
10487                        current.updated_at_unix_ms = unix_time_ms();
10488                        current.last_error = None;
10489                        upserts.push(current.clone());
10490                    }
10491                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10492                        current.active_session = None;
10493                        current.state = detached_record_state(current);
10494                        current.updated_at_unix_ms = unix_time_ms();
10495                        upserts.push(current.clone());
10496                    }
10497                    ControlEventKind::Failed { message }
10498                    | ControlEventKind::ResumeFailed { message } => {
10499                        current.active_session = None;
10500                        current.state = detached_record_state(current);
10501                        current.updated_at_unix_ms = unix_time_ms();
10502                        current.last_error = Some(
10503                            session_registry::sanitized_record_error_summary(message),
10504                        );
10505                        upserts.push(current.clone());
10506                    }
10507                    ControlEventKind::ResumeDenied { reason } => {
10508                        current.active_session = None;
10509                        current.state = detached_record_state(current);
10510                        current.updated_at_unix_ms = unix_time_ms();
10511                        current.last_error = Some(
10512                            session_registry::sanitized_record_error_summary(reason),
10513                        );
10514                        upserts.push(current.clone());
10515                    }
10516                        _ => {}
10517                    }
10518                }
10519            }
10520        }
10521        let materialization_owner_record = identity_was_observed.then(|| {
10522            rebind.clone().unwrap_or_else(|| record_id.clone())
10523        }).and_then(|candidate| {
10524            self.session_records
10525                .lock()
10526                .unwrap_or_else(|poisoned| poisoned.into_inner())
10527                .records
10528                .get(&candidate)
10529                .filter(|record| {
10530                    record.provider_session.is_some()
10531                        && record.active_session.as_ref() == Some(address)
10532                })
10533                .map(|_| candidate)
10534        });
10535        if let Some(owner_record_id) = materialization_owner_record {
10536            let materialization_id = self
10537                .session_bindings
10538                .lock()
10539                .unwrap_or_else(|poisoned| poisoned.into_inner())
10540                .get(&address.session.instance_id)
10541                .and_then(|binding| binding.materialization_id.clone());
10542            if let Some(materialization_id) = materialization_id {
10543                let mut transfer_failed = false;
10544                {
10545                    let mut materializations = self
10546                        .materializations
10547                        .lock()
10548                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10549                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10550                        match ownership.owner() {
10551                            MaterializationOwner::Session {
10552                                incarnation_id,
10553                                instance_id,
10554                                generation,
10555                            } if *incarnation_id == self.incarnation_id
10556                                && *instance_id == address.session.instance_id
10557                                && *generation == address.session.generation =>
10558                            {
10559                                transfer_failed = ownership
10560                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10561                                    .is_err();
10562                            }
10563                            MaterializationOwner::Record { record_id }
10564                                if record_id == &owner_record_id => {}
10565                            MaterializationOwner::Record { record_id } => {
10566                                let expected = record_id.clone();
10567                                transfer_failed = ownership
10568                                    .transfer_record_owner(
10569                                        &expected,
10570                                        owner_record_id.clone(),
10571                                        unix_time_ms(),
10572                                    )
10573                                    .is_err();
10574                            }
10575                            _ => transfer_failed = true,
10576                        }
10577                        if transfer_failed {
10578                            let _ = ownership.mark_recovery_required(unix_time_ms());
10579                        }
10580                    } else {
10581                        transfer_failed = true;
10582                    }
10583                }
10584                if transfer_failed {
10585                    if let Some(record) = self
10586                        .session_records
10587                        .lock()
10588                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10589                        .records
10590                        .get_mut(&owner_record_id)
10591                    {
10592                        record.state = ManagedSessionState::Unavailable;
10593                        record.last_error = Some("environment-profile-unavailable".to_owned());
10594                        record.updated_at_unix_ms = unix_time_ms();
10595                        upserts.push(record.clone());
10596                    }
10597                }
10598            }
10599        }
10600        if let Some(new_record_id) = rebind.clone() {
10601            if let Some(binding) = self
10602                .session_bindings
10603                .lock()
10604                .unwrap_or_else(|poisoned| poisoned.into_inner())
10605                .get_mut(&address.session.instance_id)
10606            {
10607                binding.record_id = Some(new_record_id);
10608            }
10609        }
10610        if upserts.is_empty() && removals.is_empty() {
10611            return;
10612        }
10613        if self.persist_state_locked().is_err() {
10614            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10615            let mut records = self
10616                .session_records
10617                .lock()
10618                .unwrap_or_else(|poisoned| poisoned.into_inner());
10619            records.records = original_records;
10620            *self
10621                .materializations
10622                .lock()
10623                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10624            if let Some(current) = records.records.get_mut(record_id) {
10625                current.state = ManagedSessionState::Unavailable;
10626                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10627                current.updated_at_unix_ms = unix_time_ms();
10628                upserts = vec![current.clone()];
10629            } else {
10630                upserts.clear();
10631            }
10632            removals.clear();
10633            drop(records);
10634            if let Some(binding) = self
10635                .session_bindings
10636                .lock()
10637                .unwrap_or_else(|poisoned| poisoned.into_inner())
10638                .get_mut(&address.session.instance_id)
10639            {
10640                binding.record_id = Some(record_id.clone());
10641            }
10642            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10643        }
10644        for record in upserts {
10645            self.publish_record(record);
10646        }
10647        for record_id in removals {
10648            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10649        }
10650    }
10651
10652    fn snapshot(&self) -> NodeSnapshot {
10653        let control = self.handle.snapshot();
10654        let bindings = self
10655            .session_bindings
10656            .lock()
10657            .unwrap_or_else(|poisoned| poisoned.into_inner())
10658            .clone();
10659        let workspace_roots = self
10660            .workspaces
10661            .read()
10662            .unwrap_or_else(|poisoned| poisoned.into_inner())
10663            .clone();
10664        let environment_profiles = self
10665            .environment_profiles
10666            .read()
10667            .unwrap_or_else(|poisoned| poisoned.into_inner());
10668        let mut workspaces = workspace_roots
10669            .iter()
10670            .map(|(workspace_id, canonical_root)| {
10671                (
10672                    workspace_id.clone(),
10673                    WorkspaceSnapshot {
10674                        workspace_id: workspace_id.clone(),
10675                        canonical_root: opaque_windows_path(canonical_root.clone()),
10676                        sessions: Vec::new(),
10677                        worktree_service_mode: Some(
10678                            self.worktree_service_modes
10679                                .get(workspace_id)
10680                                .copied()
10681                                .unwrap_or(WorktreeServiceMode::Manual),
10682                        ),
10683                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10684                            profiles: self
10685                                .managed_worktree_profiles
10686                                .get(workspace_id)
10687                                .into_iter()
10688                                .flat_map(BTreeMap::values)
10689                                .map(|profile| ManagedWorktreeProfileSummary {
10690                                    id: profile.profile_id().clone(),
10691                                    revision: profile.revision().clone(),
10692                                    retention: profile.retention(),
10693                                })
10694                                .collect(),
10695                        }),
10696                    },
10697                )
10698            })
10699            .collect::<BTreeMap<_, _>>();
10700        let mut agent_progress = Vec::new();
10701        for session in &control.sessions {
10702            let Some(binding) = bindings.get(&session.instance_id) else {
10703                continue;
10704            };
10705            if binding.generation != session.generation {
10706                continue;
10707            }
10708            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10709                let address = SessionAddress {
10710                    workspace_id: binding.workspace_id.clone(),
10711                    session: SessionKey {
10712                        instance_id: session.instance_id,
10713                        generation: session.generation,
10714                    },
10715                };
10716                if let Some(progress) =
10717                    agent_progress_from_provider_snapshot(address, &session.provider)
10718                {
10719                    agent_progress.push(progress);
10720                }
10721                workspace.sessions.push(session.clone());
10722            }
10723        }
10724        NodeSnapshot {
10725            node_id: self.node_id.clone(),
10726            enabled_providers: self.enabled_providers.clone(),
10727            provider_runtime_statuses: {
10728                let updates = self
10729                    .provider_runtime_status_updates
10730                    .read()
10731                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10732                ProviderRuntimeStatuses::new(
10733                    self.provider_runtime_statuses
10734                        .iter()
10735                        .filter(|status| !updates.contains_key(status.provider()))
10736                        .cloned()
10737                        .chain(updates.values().cloned()),
10738                )
10739                .expect("refreshed provider runtime inventory remains bounded and unique")
10740            },
10741            workspaces: workspaces.into_values().collect(),
10742            session_records: self
10743                .session_records
10744                .lock()
10745                .unwrap_or_else(|poisoned| poisoned.into_inner())
10746                .records
10747                .values()
10748                .cloned()
10749                .collect(),
10750            managed_worktrees: self.managed_worktrees
10751                .lock()
10752                .unwrap_or_else(|poisoned| poisoned.into_inner())
10753                .snapshots(),
10754            launch_inventory: Some(LaunchInventory {
10755                spawn_profiles: Some(
10756                    self.spawn_profiles
10757                        .iter()
10758                        .filter_map(|profile| {
10759                            resolved_spawn_profile_summary(profile, &environment_profiles)
10760                        })
10761                        .collect(),
10762                ),
10763                bundles: Some(
10764                    self.bundle_catalog
10765                        .read()
10766                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10767                        .iter()
10768                        .map(NodeBundle::receipt)
10769                        .collect(),
10770                ),
10771                // Opaque station allowlist ids from empty-default catalog /
10772                // optional startup loader. Never secrets. Dig2 probe stays stub.
10773                network_allowlists: {
10774                    let catalog = self
10775                        .network_allowlist_catalog
10776                        .read()
10777                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10778                    if catalog.is_empty() {
10779                        None
10780                    } else {
10781                        Some(catalog.ids().cloned().collect())
10782                    }
10783                },
10784            }),
10785            agent_progress,
10786        }
10787    }
10788
10789    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10790        assert!(
10791            !matches!(
10792                &event,
10793                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10794            ),
10795            "terminal frame and agent stream events must use their own dedicated live channel",
10796        );
10797        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10798        let sequence = history
10799            .last_sequence
10800            .checked_add(1)
10801            .expect("node event sequence exhausted");
10802        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10803            if let Some(evicted) = history.events.pop_front() {
10804                history.replay_floor_sequence = evicted
10805                    .sequence
10806                    .checked_add(1)
10807                    .expect("node replay floor sequence exhausted");
10808                history.removed_record_providers.remove(&evicted.sequence);
10809            }
10810        }
10811        match &event {
10812            NodeEvent::HarnessMcpReadCall { .. } => {
10813                panic!("transient harness MCP calls must not enter durable event history")
10814            }
10815            NodeEvent::SessionRecordUpserted { record } => {
10816                history
10817                    .record_providers
10818                    .insert(record.record_id.clone(), record.provider.clone());
10819            }
10820            NodeEvent::SessionRecordRemoved { record_id } => {
10821                if let Some(provider) = history.record_providers.remove(record_id) {
10822                    history.removed_record_providers.insert(sequence, provider);
10823                }
10824            }
10825            NodeEvent::ManagedWorktreeUpserted { .. }
10826            | NodeEvent::ManagedWorktreeRemoved { .. }
10827            | NodeEvent::Control { .. }
10828            | NodeEvent::ControllerChanged { .. }
10829            | NodeEvent::SessionRecordHistorySummarized { .. }
10830            | NodeEvent::WorkspaceAdded { .. }
10831            | NodeEvent::WorkspaceRemoved { .. }
10832            | NodeEvent::ResyncRequired { .. }
10833            | NodeEvent::AgentStream { .. }
10834            | NodeEvent::TerminalFrame { .. } => {}
10835        }
10836        let envelope = NodeEventEnvelope { sequence, event };
10837        history.events.push_back(envelope.clone());
10838        history.last_sequence = sequence;
10839        let _ = self.event_tx.send(envelope.clone());
10840        drop(history);
10841        envelope
10842    }
10843
10844    /// Publishes a transient (never durable) `NodeEvent` -- today only
10845    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10846    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10847    /// why this must never ride the shared `event_tx` bus). Returns whether
10848    /// the send reached at least one subscriber: `broadcast::Sender::send`
10849    /// fails in exactly one way, no active receiver at that instant, and
10850    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10851    /// return to fail the pending call immediately rather than leave it to
10852    /// expire silently at `LOCAL_READ_TIMEOUT`/
10853    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10854    fn publish_transient(&self, event: NodeEvent) -> bool {
10855        assert!(event.requires_harness_mcp_proxy_capability());
10856        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10857        let envelope = NodeEventEnvelope { sequence: 0, event };
10858        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10859            Ok(_) => true,
10860            Err(broadcast::error::SendError(undelivered)) => {
10861                if let Some(NodeEventEnvelope {
10862                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10863                    ..
10864                }) = undelivered.first() {
10865                    // `request.content_type` only, never a per-tool label --
10866                    // this crate does not decode `request.body` and does not
10867                    // hold `hatchery-harness-api` (Nested Control Plane
10868                    // doctrine, Law 3; see this crate's dependency on
10869                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10870                    tracing::warn!(
10871                        call_id = call_id.as_str(),
10872                        content_type = ?request.content_type,
10873                        cause = "no-subscribers",
10874                        "harness MCP proxy event could not be published",
10875                    );
10876                }
10877                false
10878            }
10879        }
10880    }
10881
10882    fn publish_terminal_frames(&self) {
10883        let snapshot = self.handle.snapshot();
10884        let bindings = self
10885            .session_bindings
10886            .lock()
10887            .unwrap_or_else(|poisoned| poisoned.into_inner());
10888        let candidates = snapshot
10889            .sessions
10890            .iter()
10891            .filter_map(|session| {
10892                let binding = bindings.get(&session.instance_id)?;
10893                if binding.generation != session.generation {
10894                    return None;
10895                }
10896                let frame = session.terminal_frame.clone()?;
10897                Some((
10898                    SessionAddress {
10899                        workspace_id: binding.workspace_id.clone(),
10900                        session: SessionKey {
10901                            instance_id: session.instance_id,
10902                            generation: session.generation,
10903                        },
10904                    },
10905                    frame,
10906                ))
10907            })
10908            .collect();
10909        drop(bindings);
10910        self.publish_terminal_frame_candidates(candidates);
10911    }
10912
10913    fn publish_terminal_frame_candidates(
10914        &self,
10915        candidates: Vec<(SessionAddress, TerminalFrame)>,
10916    ) {
10917        let mut watermarks = self
10918            .terminal_frame_watermarks
10919            .lock()
10920            .unwrap_or_else(|poisoned| poisoned.into_inner());
10921        let mut events = Vec::with_capacity(candidates.len());
10922        for (address, frame) in candidates {
10923            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10924            let advanced = watermarks
10925                .get(&address.session.instance_id)
10926                .map_or(true, |(current_address, sequence)| {
10927                    current_address != &address || frame.sequence > *sequence
10928                });
10929            if !advanced {
10930                continue;
10931            }
10932            if is_first_frame {
10933                // This is the first PTY output this node has observed for
10934                // this session -- a one-time event per spawn, not per-frame
10935                // chatter (the watermark makes sure of that). A session that
10936                // reaches this line with no controller attached anywhere on
10937                // the node is running unobserved: exactly how processes from
10938                // repeated spawn attempts went orphaned for hours without a
10939                // single line about it.
10940                if self.controller_state().is_none() {
10941                    tracing::warn!(
10942                        node_id = %self.node_id,
10943                        workspace_id = %address.workspace_id,
10944                        session = ?address.session,
10945                        terminal_size = ?frame.size,
10946                        "session producing output with no controller attached",
10947                    );
10948                } else {
10949                    tracing::info!(
10950                        node_id = %self.node_id,
10951                        workspace_id = %address.workspace_id,
10952                        session = ?address.session,
10953                        terminal_size = ?frame.size,
10954                        "session produced its first output",
10955                    );
10956                }
10957            }
10958            watermarks.insert(
10959                address.session.instance_id,
10960                (address.clone(), frame.sequence),
10961            );
10962            events.push(NodeEvent::TerminalFrame { address, frame });
10963        }
10964        drop(watermarks);
10965        if events.is_empty() {
10966            return;
10967        }
10968        let mut history = self
10969            .history
10970            .lock()
10971            .unwrap_or_else(|poisoned| poisoned.into_inner());
10972        let mut envelopes = Vec::with_capacity(events.len());
10973        for event in events {
10974            let sequence = history
10975                .last_sequence
10976                .checked_add(1)
10977                .expect("node event sequence exhausted");
10978            history.last_sequence = sequence;
10979            envelopes.push(NodeEventEnvelope { sequence, event });
10980        }
10981        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10982        drop(history);
10983    }
10984
10985    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
10986    /// channel, mirroring `publish_terminal_frame_candidates` above: the
10987    /// envelope's `sequence` is drawn from the same `history.last_sequence`
10988    /// counter every other event kind shares, never a counter of its own --
10989    /// `queue_connection_event`/`queue_connection_event_batch` compare
10990    /// `envelope.sequence` against one `discard_events_through` watermark
10991    /// regardless of which broadcast channel it arrived on, so a
10992    /// per-channel counter would desynchronize that comparison.
10993    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10994        let mut history = self
10995            .history
10996            .lock()
10997            .unwrap_or_else(|poisoned| poisoned.into_inner());
10998        let sequence = history
10999            .last_sequence
11000            .checked_add(1)
11001            .expect("node event sequence exhausted");
11002        history.last_sequence = sequence;
11003        drop(history);
11004        // Debug, not info/warn -- this fires on every single chunk, but it
11005        // is exactly the counter the next drop-measurement pass needs: what
11006        // the node actually emitted (`source_sequence`, `kind`) versus what
11007        // a harness subscriber's own forwarder later logs having received
11008        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11009        // event"), so a gap between the two logs names itself instead of
11010        // needing a new counter added ad hoc.
11011        tracing::debug!(
11012            source_sequence = chunk.source_sequence,
11013            kind = agent_stream_chunk_kind_label(&chunk.kind),
11014            envelope_sequence = sequence,
11015            "publishing agent stream chunk",
11016        );
11017        let envelope = NodeEventEnvelope {
11018            sequence,
11019            event: NodeEvent::AgentStream { address, chunk },
11020        };
11021        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11022    }
11023
11024    fn current_sequence(&self) -> u64 {
11025        self.history
11026            .lock()
11027            .unwrap_or_else(|poisoned| poisoned.into_inner())
11028            .last_sequence
11029    }
11030
11031    fn controller_state(&self) -> Option<ControllerState> {
11032        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11033        let lease = controller.as_ref().copied()?;
11034        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11035        if remaining.is_zero() {
11036            *controller = None;
11037            return None;
11038        }
11039        Some(ControllerState {
11040            connection_id: lease.connection_id,
11041            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11042        })
11043    }
11044
11045    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11046        if role != ClientRole::Operator {
11047            tracing::warn!(
11048                node_id = %self.node_id,
11049                connection_id,
11050                "controller lease rejected: observer connections cannot acquire control",
11051            );
11052            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11053        }
11054        let lease_ms = if lease_ms == 0 {
11055            DEFAULT_CONTROLLER_LEASE_MS
11056        } else {
11057            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11058        };
11059        let now = Instant::now();
11060        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11061        if let Some(active) = controller.as_ref() {
11062            if active.connection_id != connection_id && active.expires_at > now {
11063                tracing::warn!(
11064                    node_id = %self.node_id,
11065                    connection_id,
11066                    holder_connection_id = active.connection_id,
11067                    "controller lease rejected: another operator holds it",
11068                );
11069                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11070            }
11071        }
11072        // A reconnect (this connection already holds the lease, renewing
11073        // before expiry) happens on a periodic cadence; logging it at INFO
11074        // every renewal would be exactly the heartbeat chatter this crate
11075        // family's level discipline forbids. A genuine attach -- the
11076        // controller identity actually changing -- is a state change and
11077        // gets INFO; a same-connection renewal gets DEBUG.
11078        let is_new_attach = match controller.as_ref() {
11079            Some(active) => active.connection_id != connection_id,
11080            None => true,
11081        };
11082        *controller = Some(ControllerLease {
11083            connection_id,
11084            expires_at: now + Duration::from_millis(lease_ms),
11085        });
11086        drop(controller);
11087        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11088        if is_new_attach {
11089            tracing::info!(
11090                node_id = %self.node_id,
11091                connection_id,
11092                lease_ms,
11093                "controller lease acquired",
11094            );
11095        } else {
11096            tracing::debug!(
11097                node_id = %self.node_id,
11098                connection_id,
11099                lease_ms,
11100                "controller lease renewed",
11101            );
11102        }
11103        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11104        Ok(state)
11105    }
11106
11107    fn release_controller(&self, connection_id: u64) -> bool {
11108        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11109        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11110        if released {
11111            *controller = None;
11112        }
11113        drop(controller);
11114        if released {
11115            tracing::info!(
11116                node_id = %self.node_id,
11117                connection_id,
11118                "controller lease released",
11119            );
11120            self.publish(NodeEvent::ControllerChanged { controller: None });
11121        }
11122        released
11123    }
11124
11125    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11126        if role != ClientRole::Operator {
11127            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11128        }
11129        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11130        let now = Instant::now();
11131        match controller.as_ref().copied() {
11132            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11133            Some(active) if active.expires_at <= now => {
11134                *controller = None;
11135                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11136            }
11137            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11138        }
11139    }
11140
11141    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11142        if !self
11143            .workspaces
11144            .read()
11145            .unwrap_or_else(|poisoned| poisoned.into_inner())
11146            .contains_key(&address.workspace_id)
11147        {
11148            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11149        }
11150        let binding = self
11151            .session_bindings
11152            .lock()
11153            .unwrap_or_else(|poisoned| poisoned.into_inner())
11154            .get(&address.session.instance_id)
11155            .cloned()
11156            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11157        if binding.workspace_id != address.workspace_id {
11158            return Err(failure(
11159                NodeFailureCode::SessionWorkspaceMismatch,
11160                "session belongs to another workspace",
11161            ));
11162        }
11163        if binding.generation != address.session.generation {
11164            return Err(failure(
11165                NodeFailureCode::StaleGeneration,
11166                &format!(
11167                    "stale session generation: expected {}, received {}",
11168                    binding.generation.0,
11169                    address.session.generation.0,
11170                ),
11171            ));
11172        }
11173        let snapshot = self.handle.snapshot();
11174        let Some(current) = snapshot
11175            .sessions
11176            .iter()
11177            .find(|item| item.instance_id == address.session.instance_id)
11178        else {
11179            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11180        };
11181        if current.generation != address.session.generation {
11182            return Err(failure(
11183                NodeFailureCode::StaleGeneration,
11184                &format!(
11185                    "stale session generation: expected {}, received {}",
11186                    current.generation.0,
11187                    address.session.generation.0,
11188                ),
11189            ));
11190        }
11191        Ok(current.agent_id.clone())
11192    }
11193
11194    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11195        self.dispatch_envelope(self.prepare_command(command))
11196    }
11197
11198    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11199        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11200        CommandEnvelope {
11201            id: CommandId(id),
11202            command,
11203        }
11204    }
11205
11206    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11207        let command_id = envelope.id;
11208        self.handle
11209            .dispatch(envelope)
11210            .map_err(|error| match error {
11211                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11212                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11213            })?;
11214        Ok(command_id)
11215    }
11216
11217    /// Records the kernel's own reason for asynchronously refusing
11218    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11219    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11220    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11221    /// first, and if the map is still at capacity after that, the single
11222    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11223    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11224    /// is always the oldest.
11225    fn record_rejected_command(
11226        &self,
11227        command_id: CommandId,
11228        message: String,
11229        unsupported_transport: bool,
11230    ) {
11231        let now = Instant::now();
11232        let mut reasons = self.rejected_command_reasons.lock()
11233            .unwrap_or_else(|poisoned| poisoned.into_inner());
11234        reasons.retain(|_, reason| reason.expires_at > now);
11235        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11236            if let Some(&oldest) = reasons.keys().next() {
11237                reasons.remove(&oldest);
11238            }
11239        }
11240        reasons.insert(
11241            command_id,
11242            RejectedCommandReason {
11243                message,
11244                unsupported_transport,
11245                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11246            },
11247        );
11248    }
11249
11250    /// Removes and returns the kernel's reason for refusing `command_id`,
11251    /// if the drive loop has recorded one and it has not expired -- see
11252    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11253    /// wait loop calls this instead of blindly polling out its whole
11254    /// deadline when the command it is waiting on turns out to have been
11255    /// refused rather than merely slow.
11256    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11257        let now = Instant::now();
11258        let mut reasons = self.rejected_command_reasons.lock()
11259            .unwrap_or_else(|poisoned| poisoned.into_inner());
11260        match reasons.remove(&command_id) {
11261            Some(reason) if reason.expires_at > now => Some(reason),
11262            _ => None,
11263        }
11264    }
11265
11266    async fn dispatch_bounded(
11267        &self,
11268        command: ControlCommand,
11269        timeout_duration: Duration,
11270    ) -> Result<CommandId, NodeFailure> {
11271        let deadline = Instant::now() + timeout_duration;
11272        loop {
11273            match self.dispatch(command.clone()) {
11274                Ok(command_id) => return Ok(command_id),
11275                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11276                    sleep(Duration::from_millis(2)).await;
11277                }
11278                Err(error) => return Err(error),
11279            }
11280        }
11281    }
11282
11283    fn internal_session_snapshot(
11284        &self,
11285        address: &SessionAddress,
11286    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11287        self.validate_address(address)?;
11288        self.handle
11289            .snapshot()
11290            .sessions
11291            .clone()
11292            .into_iter()
11293            .find(|session| {
11294                session.instance_id == address.session.instance_id
11295                    && session.generation == address.session.generation
11296            })
11297            .ok_or_else(|| {
11298                failure(
11299                    NodeFailureCode::UnknownSession,
11300                    "session snapshot is unavailable",
11301                )
11302            })
11303    }
11304
11305    /// Confirms `address` names a live session spawned over the ACP
11306    /// transport and returns its current snapshot -- the gate every
11307    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11308    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11309    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11310    /// the session's own recorded `TransportKind` does, and that lives on
11311    /// the live snapshot, not the binding.
11312    fn require_acp_session(
11313        &self,
11314        address: &SessionAddress,
11315    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11316        let snapshot = self.internal_session_snapshot(address)?;
11317        if snapshot.transport != TransportKind::Acp {
11318            return Err(failure(
11319                NodeFailureCode::UnsupportedTransport,
11320                "this verb requires a session spawned over the ACP transport",
11321            ));
11322        }
11323        Ok(snapshot)
11324    }
11325
11326    async fn dispatch_history_bounded(
11327        &self,
11328        address: &SessionAddress,
11329        command: ControlCommand,
11330        operation: HistoryOperation,
11331    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11332        self.validate_address(address)?;
11333        let started_after = self.current_sequence();
11334        let command_id = self
11335            .dispatch_bounded(
11336                command,
11337                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11338            )
11339            .await?;
11340        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11341        let mut scan_after = started_after;
11342        let mut accepted_after = None;
11343        loop {
11344            let events = self
11345                .history
11346                .lock()
11347                .unwrap_or_else(|poisoned| poisoned.into_inner())
11348                .events
11349                .iter()
11350                .filter(|envelope| envelope.sequence > scan_after)
11351                .cloned()
11352                .collect::<Vec<_>>();
11353            for envelope in events {
11354                scan_after = scan_after.max(envelope.sequence);
11355                let NodeEvent::Control {
11356                    address: event_address,
11357                    event,
11358                } = envelope.event
11359                else {
11360                    continue;
11361                };
11362                if event_address != *address {
11363                    continue;
11364                }
11365                if event.command_id == Some(command_id) {
11366                    match &event.event {
11367                        ControlEventKind::CommandRejected { .. } => {
11368                            return Err(failure(
11369                                NodeFailureCode::InvalidRequest,
11370                                "history request was rejected",
11371                            ));
11372                        }
11373                        ControlEventKind::HistoryRequested {
11374                            operation: requested,
11375                            ..
11376                        } if requested == &operation => {
11377                            accepted_after = Some(envelope.sequence);
11378                            continue;
11379                        }
11380                        _ => {}
11381                    }
11382                }
11383                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11384                    match (&operation, &event.event) {
11385                        (
11386                            HistoryOperation::Discover { .. },
11387                            ControlEventKind::HistoryDiscovered { .. },
11388                        )
11389                        | (
11390                            HistoryOperation::Load { .. },
11391                            ControlEventKind::HistoryLoaded { .. },
11392                        ) => return self.internal_session_snapshot(address),
11393                        (_, ControlEventKind::HistoryFailed { .. }) => {
11394                            return Err(failure(
11395                                NodeFailureCode::BackendOperationFailed,
11396                                "history operation failed",
11397                            ));
11398                        }
11399                        _ => {}
11400                    }
11401                }
11402            }
11403            if Instant::now() >= deadline {
11404                return Err(failure(
11405                    NodeFailureCode::BackendBusy,
11406                    if accepted_after.is_some() {
11407                        "history operation did not settle before the bounded deadline"
11408                    } else {
11409                        "history operation was not accepted before the bounded deadline"
11410                    },
11411                ));
11412            }
11413            sleep(Duration::from_millis(2)).await;
11414        }
11415    }
11416
11417    async fn discover_history(
11418        &self,
11419        address: &SessionAddress,
11420        limit: u16,
11421    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11422        let query = HistoryQuery {
11423            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11424            limit,
11425        };
11426        query
11427            .validate()
11428            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11429        let snapshot = self
11430            .dispatch_history_bounded(
11431                address,
11432                ControlCommand::DiscoverHistory {
11433                    instance_id: address.session.instance_id,
11434                    query: query.clone(),
11435                },
11436                HistoryOperation::Discover { query },
11437            )
11438            .await?;
11439        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11440            return Err(failure(
11441                NodeFailureCode::BackendOperationFailed,
11442                "history discovery settled into an invalid state",
11443            ));
11444        }
11445        Ok(snapshot.history.candidates)
11446    }
11447
11448    async fn load_history(
11449        &self,
11450        address: &SessionAddress,
11451        candidate_id: String,
11452    ) -> Result<HistorySessionRecord, NodeFailure> {
11453        validate_candidate_id(&candidate_id)
11454            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11455        let snapshot = self
11456            .dispatch_history_bounded(
11457                address,
11458                ControlCommand::LoadHistory {
11459                    instance_id: address.session.instance_id,
11460                    candidate_id: candidate_id.clone(),
11461                },
11462                HistoryOperation::Load {
11463                    candidate_id: candidate_id.clone(),
11464                },
11465            )
11466            .await?;
11467        if snapshot.history.pending.is_some()
11468            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11469        {
11470            return Err(failure(
11471                NodeFailureCode::BackendOperationFailed,
11472                "history load settled into an invalid state",
11473            ));
11474        }
11475        snapshot.history.loaded.ok_or_else(|| {
11476            failure(
11477                NodeFailureCode::BackendOperationFailed,
11478                "history load produced no bounded session",
11479            )
11480        })
11481    }
11482
11483    /// `allow_clean_detachment`: `false` for the on-demand
11484    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11485    /// managed record must stay untouched, still live, for the whole call);
11486    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11487    /// reacts to the very clean-exit event that detaches the record out from
11488    /// under this same call — see `session_record_export_target_matches`.
11489    async fn export_context_pack_for_session_record(
11490        &self,
11491        record_id: &SessionRecordId,
11492        session: &SessionAddress,
11493        allow_clean_detachment: bool,
11494    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11495        // C2 replaces a relayed failure's prose with a canned per-code
11496        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11497        // distinct refusals are indistinguishable to anyone downstream --
11498        // and a refusal here expires a continuation, which keeps no field
11499        // for why. The prose stays on the node, where it was written.
11500        let outcome = self
11501            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11502            .await;
11503        if let Err(failure) = &outcome {
11504            tracing::warn!(
11505                record = %record_id,
11506                code = ?failure.code,
11507                reason = %failure.message,
11508                "session-record context-pack export refused",
11509            );
11510        }
11511        outcome
11512    }
11513
11514    async fn export_context_pack_for_session_record_inner(
11515        &self,
11516        record_id: &SessionRecordId,
11517        session: &SessionAddress,
11518        allow_clean_detachment: bool,
11519    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11520        // Several steps here refuse with the same BackendOperationFailed
11521        // code and no prose of their own, so which step failed is
11522        // otherwise unrecoverable -- and a refusal here expires a
11523        // continuation.
11524        let step = |name: &'static str| move |failure: NodeFailure| {
11525            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11526            failure
11527        };
11528        let record = self.record(record_id).map_err(step("record-lookup"))?;
11529        // `provider_session` is `Some` only for a provider whose resolved
11530        // runtime policy admits live identity verification (raw PTY plus a
11531        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11532        // The PTY-sidecar-observation shape (no fleet provider today, but
11533        // capability-keyed rather than vendor-locked) hardcodes
11534        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11535        // so its records go straight to `Live` with `provider_session: None`
11536        // and stay that way for the record's entire life -- that is this
11537        // shape's permanent resting state, not a race with observation, and
11538        // `discover_history`/`load_history` below do not need an identity to
11539        // do their job for it. `IdentityPending` is the actual race (an
11540        // identity-admitted provider whose observation has not landed yet):
11541        // it keeps refusing here rather than falling back, because that
11542        // provider's own history the fallback would otherwise trust is not
11543        // guaranteed to be exact without the identity it is still waiting on.
11544        let identity = match record.provider_session.clone() {
11545            Some(identity) => {
11546                if identity.key != ProviderSessionKey::SessionId {
11547                    return Err(step("provider-identity-key")(failure(
11548                        NodeFailureCode::BackendOperationFailed,
11549                        "managed session provider identity cannot bind history exactly",
11550                    )));
11551                }
11552                Some(identity)
11553            }
11554            None if record.state == ManagedSessionState::IdentityPending => {
11555                return Err(step("provider-session-identity")(failure(
11556                    NodeFailureCode::BackendOperationFailed,
11557                    "managed session has not yet observed its provider session identity",
11558                )));
11559            }
11560            None => None,
11561        };
11562        self.revalidate_session_record_context_export(
11563            &record,
11564            identity.as_ref(),
11565            session,
11566            allow_clean_detachment,
11567        ).map_err(step("revalidate-before-discovery"))?;
11568        let candidates = self
11569            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11570            .await
11571            .map_err(step("discover-history"))?;
11572        // With no verified identity to filter by, the discovered set must
11573        // still resolve to exactly one candidate: the same ambiguity refusal
11574        // an identity filter would otherwise produce is the only safety
11575        // property left available for this shape.
11576        let candidate_id = select_history_candidate(
11577            &candidates,
11578            identity.as_ref().map(|identity| identity.id.as_str()),
11579        ).map_err(step("unique-candidate"))?;
11580        let loaded = self.load_history(session, candidate_id.clone()).await
11581            .map_err(step("load-history"))?;
11582        if let Some(identity) = identity.as_ref() {
11583            if loaded.session_id != identity.id {
11584                return Err(failure(
11585                    NodeFailureCode::SessionRecordConflict,
11586                    "loaded history identity does not match the managed session record",
11587                ));
11588            }
11589        }
11590        self.revalidate_session_record_context_export(
11591            &record,
11592            identity.as_ref(),
11593            session,
11594            allow_clean_detachment,
11595        )?;
11596        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11597            .map_err(step("revalidate-loaded-history"))?;
11598        let pack = self.materialize_context_pack(session).await
11599            .map_err(step("materialize-context-pack"))?;
11600        self.commit_context_pack_for_session_record(
11601            &record,
11602            identity.as_ref(),
11603            session,
11604            &candidate_id,
11605            &loaded,
11606            pack,
11607            allow_clean_detachment,
11608        )
11609    }
11610
11611    async fn export_context_pack(
11612        &self,
11613        address: &SessionAddress,
11614    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11615        let pack = self.materialize_context_pack(address).await?;
11616        self.commit_context_pack(pack)
11617    }
11618
11619    async fn materialize_context_pack(
11620        &self,
11621        address: &SessionAddress,
11622    ) -> Result<NodeContextPack, NodeFailure> {
11623        let snapshot = self.internal_session_snapshot(address)?;
11624        if !context_pack_source_status_is_usable(&snapshot.status) {
11625            return Err(failure(
11626                NodeFailureCode::InvalidRequest,
11627                "context export requires a running or successfully exited source session",
11628            ));
11629        }
11630        if snapshot.history.pending.is_some()
11631            || snapshot.history.loaded_candidate_id.is_none()
11632        {
11633            return Err(failure(
11634                NodeFailureCode::InvalidRequest,
11635                "context export requires settled loaded history",
11636            ));
11637        }
11638        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11639            failure(
11640                NodeFailureCode::InvalidRequest,
11641                "context export requires settled loaded history",
11642            )
11643        })?;
11644        let source_provider = snapshot.agent_id.clone();
11645        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11646        let repository = self
11647            .context_pack_repository(&address.workspace_id)
11648            .await?;
11649        let current = self.internal_session_snapshot(address)?;
11650        if !context_pack_source_status_is_usable(&current.status)
11651            || current.agent_id != source_provider
11652            || current.history.pending.is_some()
11653            || current.history.loaded_candidate_id != loaded_candidate_id
11654            || current.history.loaded.as_ref() != Some(&loaded)
11655        {
11656            return Err(failure(
11657                NodeFailureCode::ContextPackMaterializationFailed,
11658                "context source changed during bounded repository capture",
11659            ));
11660        }
11661        NodeContextPack::export_with_repository(
11662            ContextPackLineageReceipt {
11663                source_node_id: self.node_id.clone(),
11664                source_session: address.clone(),
11665                source_provider,
11666            },
11667            &loaded,
11668            Some(repository),
11669        )
11670        .map_err(|_| {
11671            failure(
11672                NodeFailureCode::ContextPackMaterializationFailed,
11673                "bounded context pack export failed",
11674            )
11675        })
11676    }
11677
11678    /// Write-through to the durable [`ContextPackStore`], if one is
11679    /// configured (`state_path` is `Some`). A node with no durable state
11680    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11681    /// open at all and this is a no-op, matching `persist_state`'s own
11682    /// "durability optional, degrade gracefully when unconfigured" idiom.
11683    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11684        let mut store = self
11685            .context_pack_store
11686            .lock()
11687            .unwrap_or_else(|poisoned| poisoned.into_inner());
11688        let Some(store) = store.as_mut() else {
11689            return Ok(());
11690        };
11691        store.commit(pack).map_err(|_| {
11692            failure(
11693                NodeFailureCode::ContextPackMaterializationFailed,
11694                "durable context pack commit failed",
11695            )
11696        })
11697    }
11698
11699    fn commit_context_pack(
11700        &self,
11701        pack: NodeContextPack,
11702    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11703        self.commit_context_pack_durably(&pack)?;
11704        self.context_catalog
11705            .write()
11706            .unwrap_or_else(|poisoned| poisoned.into_inner())
11707            .insert(pack)
11708            .map_err(|_| {
11709                failure(
11710                    NodeFailureCode::ContextPackMaterializationFailed,
11711                    "context pack catalog is full",
11712                )
11713            })
11714    }
11715
11716    fn commit_context_pack_for_session_record(
11717        &self,
11718        expected: &ManagedSessionRecord,
11719        identity: Option<&ProviderSessionIdentity>,
11720        session: &SessionAddress,
11721        candidate_id: &str,
11722        loaded: &HistorySessionRecord,
11723        pack: NodeContextPack,
11724        allow_clean_detachment: bool,
11725    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11726        let current_root = self.workspace_root(&expected.workspace_id)?;
11727        let _transaction = self
11728            .state_transaction
11729            .lock()
11730            .unwrap_or_else(|poisoned| poisoned.into_inner());
11731        let records = self
11732            .session_records
11733            .lock()
11734            .unwrap_or_else(|poisoned| poisoned.into_inner());
11735        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11736            failure(
11737                NodeFailureCode::SessionRecordConflict,
11738                "managed session record disappeared during context export",
11739            )
11740        })?;
11741        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11742            return Err(failure(
11743                NodeFailureCode::SessionRecordConflict,
11744                "managed session record changed during context export",
11745            ));
11746        }
11747        let bindings = self
11748            .session_bindings
11749            .lock()
11750            .unwrap_or_else(|poisoned| poisoned.into_inner());
11751        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11752            failure(
11753                NodeFailureCode::SessionRecordConflict,
11754                "managed session runtime binding disappeared during context export",
11755            )
11756        })?;
11757        let runtime = self
11758            .handle
11759            .snapshot()
11760            .sessions
11761            .iter()
11762            .find(|snapshot| {
11763                snapshot.instance_id == session.session.instance_id
11764                    && snapshot.generation == session.session.generation
11765            })
11766            .cloned()
11767            .ok_or_else(|| {
11768                failure(
11769                    NodeFailureCode::SessionRecordConflict,
11770                    "managed session runtime disappeared during context export",
11771                )
11772            })?;
11773        let receipt = pack.receipt();
11774        if !session_record_context_export_binding_is_exact(
11775            expected,
11776            current,
11777            identity,
11778            session,
11779            binding,
11780            &runtime.agent_id,
11781            &current_root,
11782            allow_clean_detachment,
11783        ) || !session_record_context_export_source_is_usable(
11784            current,
11785            &runtime.status,
11786            allow_clean_detachment,
11787        ) || runtime.history.pending.is_some()
11788            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11789            || runtime.history.loaded.as_ref() != Some(loaded)
11790            || receipt.lineage.source_node_id != self.node_id
11791            || receipt.lineage.source_session != *session
11792            || receipt.lineage.source_provider != current.provider
11793            || receipt.source_message_count != loaded.message_count
11794        {
11795            return Err(failure(
11796                NodeFailureCode::SessionRecordConflict,
11797                "managed session source changed before context catalog commit",
11798            ));
11799        }
11800        self.commit_context_pack_durably(&pack)?;
11801        self.context_catalog
11802            .write()
11803            .unwrap_or_else(|poisoned| poisoned.into_inner())
11804            .insert(pack)
11805            .map_err(|_| {
11806                failure(
11807                    NodeFailureCode::ContextPackMaterializationFailed,
11808                    "context pack catalog is full",
11809                )
11810            })
11811    }
11812
11813    async fn context_pack_repository(
11814        &self,
11815        workspace_id: &WorkspaceId,
11816    ) -> Result<ContextPackRepository, NodeFailure> {
11817        let first = self
11818            .context_pack_repository_observation(workspace_id)
11819            .await?;
11820        let second = self
11821            .context_pack_repository_observation(workspace_id)
11822            .await?;
11823        stable_context_pack_repository(first, second)
11824    }
11825
11826    async fn context_pack_repository_observation(
11827        &self,
11828        workspace_id: &WorkspaceId,
11829    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11830        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11831        let head = if git.is_repository {
11832            let root = self.workspace_root(workspace_id)?;
11833            match resolve_base_commit_with_timeout(
11834                &root,
11835                "HEAD",
11836                GIT_COMMAND_TIMEOUT_MS,
11837            )
11838            .await
11839            {
11840                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11841                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11842                    ContextPackRepositoryHead::Unborn
11843                }
11844                Err(_) => {
11845                    return Err(failure(
11846                        NodeFailureCode::ContextPackMaterializationFailed,
11847                        "context pack HEAD observation failed",
11848                    ));
11849                }
11850            }
11851        } else {
11852            ContextPackRepositoryHead::NotRepository
11853        };
11854        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11855        for selected_path in CONTEXT_PACK_SELECTED_FILES {
11856            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11857                failure(
11858                    NodeFailureCode::InvalidRepositoryPath,
11859                    "context pack selected file path is invalid",
11860                )
11861            })?;
11862            match self.read_workspace_file(workspace_id.clone(), path).await {
11863                Ok(WorkspaceFileRead {
11864                    content: WorkspaceFileContent::Utf8 { text, byte_len },
11865                    ..
11866                }) => files.push(ContextPackRepositoryFileSource::utf8(
11867                    selected_path,
11868                    text,
11869                    byte_len,
11870                )),
11871                Ok(WorkspaceFileRead {
11872                    content: WorkspaceFileContent::NonUtf8 { byte_len },
11873                    ..
11874                }) => files.push(ContextPackRepositoryFileSource::skipped(
11875                    selected_path,
11876                    ContextPackSelectedFileSkipReason::NonUtf8,
11877                    Some(byte_len),
11878                )),
11879                Ok(WorkspaceFileRead {
11880                    content: WorkspaceFileContent::TooLarge { .. },
11881                    ..
11882                }) => files.push(ContextPackRepositoryFileSource::skipped(
11883                    selected_path,
11884                    ContextPackSelectedFileSkipReason::TooLarge,
11885                    None,
11886                )),
11887                Err(error) => {
11888                    let reason = if matches!(
11889                        error.code,
11890                        NodeFailureCode::InvalidRepositoryPath
11891                            | NodeFailureCode::RepositoryFileNotRegular
11892                            | NodeFailureCode::RepositoryPathUnsafe
11893                    ) {
11894                        ContextPackSelectedFileSkipReason::Unsafe
11895                    } else {
11896                        ContextPackSelectedFileSkipReason::Unavailable
11897                    };
11898                    files.push(ContextPackRepositoryFileSource::skipped(
11899                        selected_path,
11900                        reason,
11901                        None,
11902                    ));
11903                }
11904            }
11905        }
11906        Ok(ContextPackRepositoryObservation { head, git, files })
11907    }
11908
11909    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11910        let referenced_by_binding = self
11911            .session_bindings
11912            .lock()
11913            .unwrap_or_else(|poisoned| poisoned.into_inner())
11914            .values()
11915            .any(|binding| {
11916                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11917            });
11918        let referenced_by_record = self
11919            .session_records
11920            .lock()
11921            .unwrap_or_else(|poisoned| poisoned.into_inner())
11922            .records
11923            .values()
11924            .any(|record| record.context_id.as_ref() == Some(context_id));
11925        let referenced_by_materialization = self
11926            .materializations
11927            .lock()
11928            .unwrap_or_else(|poisoned| poisoned.into_inner())
11929            .values()
11930            .any(|ownership| {
11931                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11932            });
11933        if referenced_by_binding
11934            || referenced_by_record
11935            || referenced_by_materialization
11936        {
11937            return Err(failure(
11938                NodeFailureCode::ContextPackBusy,
11939                "context pack is still referenced",
11940            ));
11941        }
11942        let mut catalog = self.context_catalog
11943            .write()
11944            .unwrap_or_else(|poisoned| poisoned.into_inner());
11945        if catalog.get(context_id).is_none() {
11946            return Err(failure(
11947                NodeFailureCode::UnknownContextPack,
11948                "context pack is unavailable",
11949            ));
11950        }
11951        self.spawn_idempotency
11952            .lock()
11953            .unwrap_or_else(|poisoned| poisoned.into_inner())
11954            .entries
11955            .retain(|_, entry| !entry.value.references_context(context_id));
11956        let removed = catalog.remove(context_id);
11957        debug_assert!(removed.is_some(), "validated context pack remains present");
11958        Ok(())
11959    }
11960
11961    async fn dispatch_input_bounded(
11962        &self,
11963        address: &SessionAddress,
11964        action: InputAction,
11965    ) -> Result<(), NodeFailure> {
11966        let started_after = self.current_sequence();
11967        let command_id = self
11968            .dispatch_bounded(
11969                ControlCommand::SendInput {
11970                    instance_id: address.session.instance_id,
11971                    action,
11972                },
11973                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11974            )
11975            .await?;
11976        // A semantic prompt may spend the provider's full readiness budget before
11977        // the runtime emits InputCompleted. Keep the node request deadline bounded,
11978        // but strictly beyond every readiness policy in this node's catalog.
11979        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11980        let mut scan_after = started_after;
11981        let mut accepted_after = None;
11982        loop {
11983            let events = self
11984                .history
11985                .lock()
11986                .unwrap_or_else(|poisoned| poisoned.into_inner())
11987                .events
11988                .iter()
11989                .filter(|envelope| envelope.sequence > scan_after)
11990                .cloned()
11991                .collect::<Vec<_>>();
11992            for envelope in events {
11993                scan_after = scan_after.max(envelope.sequence);
11994                let NodeEvent::Control {
11995                    address: event_address,
11996                    event,
11997                } = envelope.event
11998                else {
11999                    continue;
12000                };
12001                if event_address != *address {
12002                    continue;
12003                }
12004                if event.command_id == Some(command_id) {
12005                    match &event.event {
12006                        ControlEventKind::CommandRejected { message } => {
12007                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12008                        }
12009                        ControlEventKind::InputRequested { .. } => {
12010                            accepted_after = Some(envelope.sequence);
12011                            continue;
12012                        }
12013                        _ => {}
12014                    }
12015                }
12016                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12017                    match &event.event {
12018                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12019                        ControlEventKind::InputFailed { message, .. } => {
12020                            return Err(failure(
12021                                NodeFailureCode::BackendOperationFailed,
12022                                message,
12023                            ));
12024                        }
12025                        _ => {}
12026                    }
12027                }
12028            }
12029            if Instant::now() >= deadline {
12030                return Err(failure(
12031                    NodeFailureCode::BackendBusy,
12032                    if accepted_after.is_some() {
12033                        "input was accepted but did not settle before the bounded deadline"
12034                    } else {
12035                        "input was not accepted before the bounded deadline"
12036                    },
12037                ));
12038            }
12039            sleep(Duration::from_millis(2)).await;
12040        }
12041    }
12042
12043    async fn dispatch_resize_bounded(
12044        &self,
12045        address: &SessionAddress,
12046        size: gate4agent_types::TerminalSize,
12047    ) -> Result<(), NodeFailure> {
12048        let started_after = self.current_sequence();
12049        let command_id = self
12050            .dispatch_bounded(
12051                ControlCommand::Resize {
12052                    instance_id: address.session.instance_id,
12053                    size,
12054                },
12055                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12056            )
12057            .await?;
12058        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12059        let mut scan_after = started_after;
12060        let mut accepted_after = None;
12061        loop {
12062            let events = self
12063                .history
12064                .lock()
12065                .unwrap_or_else(|poisoned| poisoned.into_inner())
12066                .events
12067                .iter()
12068                .filter(|envelope| envelope.sequence > scan_after)
12069                .cloned()
12070                .collect::<Vec<_>>();
12071            for envelope in events {
12072                scan_after = scan_after.max(envelope.sequence);
12073                let NodeEvent::Control {
12074                    address: event_address,
12075                    event,
12076                } = envelope.event
12077                else {
12078                    continue;
12079                };
12080                if event_address != *address {
12081                    continue;
12082                }
12083                if event.command_id == Some(command_id) {
12084                    match &event.event {
12085                        ControlEventKind::CommandRejected { message } => {
12086                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12087                        }
12088                        ControlEventKind::ResizeRequested {
12089                            size: requested_size,
12090                            ..
12091                        } if *requested_size == size => {
12092                            accepted_after = Some(envelope.sequence);
12093                            continue;
12094                        }
12095                        _ => {}
12096                    }
12097                }
12098                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12099                    match &event.event {
12100                        ControlEventKind::Resized { size: settled_size }
12101                            if *settled_size == size =>
12102                        {
12103                            return Ok(());
12104                        }
12105                        ControlEventKind::ResizeFailed { message } => {
12106                            return Err(failure(
12107                                NodeFailureCode::BackendOperationFailed,
12108                                message,
12109                            ));
12110                        }
12111                        _ => {}
12112                    }
12113                }
12114            }
12115            if Instant::now() >= deadline {
12116                return Err(failure(
12117                    NodeFailureCode::BackendBusy,
12118                    if accepted_after.is_some() {
12119                        "resize was accepted but did not settle before the bounded deadline"
12120                    } else {
12121                        "resize was not accepted before the bounded deadline"
12122                    },
12123                ));
12124            }
12125            sleep(Duration::from_millis(2)).await;
12126        }
12127    }
12128
12129    async fn spawn_session(
12130        &self,
12131        workspace_id: WorkspaceId,
12132        provider: AgentId,
12133        mode: SessionMode,
12134        terminal_size: gate4agent_types::TerminalSize,
12135        initial_prompt: Option<String>,
12136    ) -> Result<SessionAddress, NodeFailure> {
12137        if self.managed_worktrees.lock()
12138            .unwrap_or_else(|poisoned| poisoned.into_inner())
12139            .lease_for_workspace(&workspace_id)
12140            .is_some()
12141        {
12142            return Err(failure(
12143                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12144                "managed worktree targets may only be spawned through their lease request",
12145            ));
12146        }
12147        self
12148            .spawn_session_with_deadline(
12149                workspace_id,
12150                provider,
12151                mode,
12152                terminal_size,
12153                initial_prompt,
12154                None,
12155                None,
12156                None,
12157                None,
12158                None,
12159                SpawnRecordPolicy::ProviderIdentityOnly,
12160                None,
12161                &[],
12162                None,
12163                // `NodeRequest::Spawn` (this method's only caller) carries no
12164                // approval-level selection of its own -- see
12165                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12166                // the one operator-wire path that can choose something other
12167                // than the axis default.
12168                ApprovalLevel::default(),
12169                None,
12170                None,
12171            )
12172            .await
12173            .map(|(session, _runtime_policy)| session)
12174    }
12175
12176    async fn spawn_session_with_deadline(
12177        &self,
12178        workspace_id: WorkspaceId,
12179        provider: AgentId,
12180        mode: SessionMode,
12181        terminal_size: gate4agent_types::TerminalSize,
12182        initial_prompt: Option<String>,
12183        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12184        bundle: Option<ResolvedBundleReceipt>,
12185        context: Option<ResolvedContextPackReceipt>,
12186        managed_authority: Option<ManagedWorktreeLeaseId>,
12187        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12188        record_policy: SpawnRecordPolicy,
12189        deadline: Option<Instant>,
12190        required_capabilities: &[ProviderRuntimeCapability],
12191        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12192        approval_level: ApprovalLevel,
12193        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12194        // Opaque dig2browser station profile id (ids only). Feature
12195        // `dig2-station-probe`: after probe at resolve, spawn takes an
12196        // exclusive node-local lease; cleared on session end. Never
12197        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12198        // already happened on the receipt path).
12199        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12200    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12201        let reserved_lease = self.managed_worktrees.lock()
12202            .unwrap_or_else(|poisoned| poisoned.into_inner())
12203            .lease_for_workspace(&workspace_id);
12204        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12205            (Some(expected), Some(provided)) if expected == provided => {}
12206            (None, None) => {}
12207            _ => {
12208                return Err(failure(
12209                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12210                    "spawn lacks exact managed worktree lease authority",
12211                ));
12212            }
12213        }
12214        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12215        // bindings) on the same workspace cwd. Parallel path: managed worktree
12216        // spawn so each profile gets its own workspace_id.
12217        self.ensure_workspace_environment_profile_compatible(
12218            &workspace_id,
12219            environment_profile.as_ref(),
12220        )?;
12221        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12222            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12223            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12224            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12225            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12226        };
12227        // This session's own transport -- computed once, here, from `mode`,
12228        // and reused both to admit spawn (below) and, further down, to
12229        // compute the final `runtime_policy` this session actually operates
12230        // under (`provider_runtime::policy_for_transport`). Registration
12231        // reuses this exact value rather than recomputing it a second time.
12232        let transport = match mode {
12233            SessionMode::Pty => TransportKind::Pty,
12234            SessionMode::Inline => TransportKind::Pipe,
12235            SessionMode::Acp => TransportKind::Acp,
12236        };
12237        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12238        let acp_transport = self.provider_supports_acp_transport(&provider);
12239        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12240            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12241                NodeFailureCode::UnsupportedSpawnCapability,
12242                "pre-admitted provider runtime no longer satisfies spawn requirements",
12243            ))?;
12244            runtime_policy
12245        } else if let Some(deadline) = deadline {
12246            let remaining = spawn_deadline_remaining(deadline)?;
12247            timeout(
12248                remaining,
12249                self.admit_provider_runtime(&provider, runtime_requirement),
12250            )
12251            .await
12252            .map_err(|_| {
12253                failure(
12254                    NodeFailureCode::SpawnDeadlineExceeded,
12255                    "provider admission exceeded the spawn deadline",
12256                )
12257            })??
12258        } else {
12259            self.admit_provider_runtime(&provider, runtime_requirement).await?
12260        };
12261        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12262        // The single point where this session's `runtime_policy` becomes
12263        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12264        // branch above) only ever compute a PTY-terminal-verification-shaped
12265        // value, sound outright only for `TransportKind::Pty` (Pipe still
12266        // relies on that same value downstream -- see `policy_for_transport`'s
12267        // own doc for why). `policy_for_transport` is the explicit,
12268        // exhaustive match Item 1 asks for -- this is the ONE place its
12269        // output becomes the session's `runtime_policy`, so every later
12270        // reader (`ControlCommand::Start`, `session_bindings`, and every
12271        // engine gate that reads `state.runtime_policy`) sees the same,
12272        // correctly-computed value.
12273        let runtime_policy =
12274            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12275        if required_capabilities
12276            .iter()
12277            .any(|capability| !runtime_policy.admits(*capability))
12278        {
12279            return Err(failure(
12280                NodeFailureCode::UnsupportedSpawnCapability,
12281                "provider runtime does not admit a required spawn capability",
12282            ));
12283        }
12284        if let Some(deadline) = deadline {
12285            spawn_deadline_remaining(deadline)?;
12286        }
12287        self.ensure_binding_capacity()?;
12288        let working_directory = self.workspace_root(&workspace_id)?;
12289        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12290        let session = SessionKey {
12291            instance_id,
12292            generation: SessionGeneration(1),
12293        };
12294        let address = SessionAddress {
12295            workspace_id: workspace_id.clone(),
12296            session,
12297        };
12298        // This is the request-accepted checkpoint: admission (managed
12299        // worktree authority, runtime capability, dispatch deadline) already
12300        // passed and a concrete session identity is minted. It is a distinct
12301        // state from the process actually running (`ControlEventKind::Running`,
12302        // logged in `publish_control`) or the terminal outcome (`Exited`/
12303        // `Failed`, logged there too) -- this line exists so that gap is no
12304        // longer silent.
12305        tracing::info!(
12306            node_id = %self.node_id,
12307            workspace_id = %workspace_id,
12308            provider = %provider,
12309            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12310            session = ?address.session,
12311            "session spawn request accepted",
12312        );
12313        let mut prepared_spawn = SpawnPreparer::prepare(
12314            self,
12315            SpawnPrepareRequest {
12316                instance_id,
12317                address: &address,
12318                provider: &provider,
12319                mode,
12320                environment_profile: environment_profile.as_ref(),
12321                bundle: bundle.as_ref(),
12322                context: context.as_ref(),
12323                managed_authority: managed_authority.as_ref(),
12324                harness_mcp,
12325                approval_level,
12326                network_allowlist: network_allowlist.as_ref(),
12327                browser_profile_id,
12328            },
12329        )?;
12330        let materialization_id = prepared_spawn.materialization_id.take();
12331        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12332        let record_id = self.bind_spawn_session_with_materialization(
12333            &address,
12334            provider.clone(),
12335            mode,
12336            runtime_policy,
12337            record_policy,
12338            environment_profile,
12339            bundle,
12340            context,
12341            materialization_id,
12342        )?;
12343        prepared_spawn.retain_session_guards();
12344        // `transport` was already computed above, alongside this session's
12345        // `runtime_policy`; reused here for `Register` rather than derived a
12346        // second time.
12347        let agent_id = provider;
12348        let register_command_id = match self.dispatch_bounded(
12349            ControlCommand::Register {
12350                instance_id,
12351                agent_id,
12352                transport,
12353            },
12354            dispatch_timeout,
12355        )
12356        .await {
12357            Ok(command_id) => command_id,
12358            Err(error) => {
12359                if let Some(binding) = self.remove_binding(&address) {
12360                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12361                }
12362                if let Some(record_id) = record_id.as_ref() {
12363                    self.discard_record(record_id)?;
12364                }
12365                return Err(spawn_dispatch_error(error, deadline));
12366            }
12367        };
12368        let start_timeout = match spawn_dispatch_timeout(deadline) {
12369            Ok(timeout) => timeout,
12370            Err(error) => {
12371                let recovery = self
12372                    .rollback_spawn(
12373                        &address,
12374                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12375                    )
12376                    .await;
12377                return match recovery {
12378                    Ok(()) => {
12379                        if let Some(record_id) = record_id.as_ref() {
12380                            self.discard_record(record_id)?;
12381                        }
12382                        Err(error)
12383                    }
12384                    Err(recovery_error) => {
12385                        if let Some(record_id) = record_id.as_ref() {
12386                            self.mark_record_error(
12387                                record_id,
12388                                "spawn deadline elapsed and registration recovery failed",
12389                            )?;
12390                        }
12391                        Err(recovery_error)
12392                    }
12393                };
12394            }
12395        };
12396        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12397            if let Err(recovery_error) = self
12398                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12399                .await
12400            {
12401                return Err(recovery_error);
12402            }
12403            if let Some(record_id) = record_id.as_ref() {
12404                self.discard_record(record_id)?;
12405            }
12406            return Err(start_error);
12407        }
12408        let start_result = self
12409            .dispatch_bounded(
12410                ControlCommand::Start {
12411                    instance_id,
12412                    runtime_policy,
12413                    request: StartRequest {
12414                        working_directory,
12415                        terminal_size,
12416                        initial_prompt,
12417                        session_options: None,
12418                        approval_level,
12419                    },
12420                },
12421                start_timeout,
12422            )
12423            .await;
12424        let start_command_id = match start_result {
12425            Ok(command_id) => command_id,
12426            Err(start_error) => {
12427                let start_error = spawn_dispatch_error(start_error, deadline);
12428                let recovery = self
12429                    .rollback_spawn(
12430                        &address,
12431                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12432                    )
12433                    .await;
12434                return match recovery {
12435                    Ok(()) => {
12436                        if let Some(record_id) = record_id.as_ref() {
12437                            self.discard_record(record_id)?;
12438                        }
12439                        Err(start_error)
12440                    }
12441                    Err(recovery_error) => {
12442                        let diagnostic = format!(
12443                            "start failed and registration recovery failed: {}",
12444                            recovery_error.message,
12445                        );
12446                        if let Some(record_id) = record_id.as_ref() {
12447                            self.mark_record_error(record_id, &diagnostic)?;
12448                        }
12449                        Err(failure(recovery_error.code, &diagnostic))
12450                    }
12451                };
12452            }
12453        };
12454
12455        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12456        loop {
12457            // `Register`/`Start` only fail synchronously for a port-level
12458            // ingress problem (see `dispatch_bounded`); the kernel refusing
12459            // either command's content -- an unsupported transport, an
12460            // unknown agent -- lands one drive-loop tick later, and no
12461            // session ever appears in `self.handle.snapshot()` for it to
12462            // notice below. Check for that named reason before falling
12463            // through to the blind presence poll, so a rejected spawn
12464            // reports why instead of just timing out.
12465            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12466                .or_else(|| self.take_rejected_command_reason(start_command_id))
12467            {
12468                self.rollback_spawn(
12469                    &address,
12470                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12471                ).await.map_err(|error| {
12472                        failure(
12473                            error.code,
12474                            &format!(
12475                                "spawn was rejected ({}) and registration recovery failed: {}",
12476                                reason.message, error.message
12477                            ),
12478                        )
12479                    })?;
12480                if let Some(record_id) = record_id.as_ref() {
12481                    self.discard_record(record_id)?;
12482                }
12483                let code = if reason.unsupported_transport {
12484                    NodeFailureCode::UnsupportedTransport
12485                } else {
12486                    NodeFailureCode::BackendOperationFailed
12487                };
12488                return Err(failure(code, &reason.message));
12489            }
12490            if let Some(current) = self
12491                .handle
12492                .snapshot()
12493                .sessions
12494                .iter()
12495                .find(|current| current.instance_id == instance_id)
12496            {
12497                if current.generation != session.generation {
12498                    let divergence = failure(
12499                        NodeFailureCode::StaleGeneration,
12500                        "spawn generation diverged",
12501                    );
12502                    return match self
12503                        .rollback_spawn(
12504                            &address,
12505                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12506                        )
12507                        .await
12508                    {
12509                        Ok(()) => {
12510                            if let Some(record_id) = record_id.as_ref() {
12511                                self.discard_record(record_id)?;
12512                            }
12513                            Err(divergence)
12514                        }
12515                        Err(recovery_error) => {
12516                            if let Some(record_id) = record_id.as_ref() {
12517                                self.mark_record_error(
12518                                    record_id,
12519                                    "spawn generation diverged and recovery failed",
12520                                )?;
12521                            }
12522                            Err(recovery_error)
12523                        }
12524                    };
12525                }
12526                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12527                    prepared_spawn.retain_launch_guards();
12528                    return Ok((address, runtime_policy));
12529                }
12530            }
12531            if Instant::now() >= commit_deadline {
12532                self.rollback_spawn(
12533                    &address,
12534                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12535                ).await.map_err(|error| {
12536                        failure(
12537                            error.code,
12538                            &format!(
12539                                "start did not commit and registration recovery failed: {}",
12540                                error.message
12541                            ),
12542                        )
12543                    })?;
12544                if let Some(record_id) = record_id.as_ref() {
12545                    self.discard_record(record_id)?;
12546                }
12547                return Err(failure(
12548                    if deadline.is_some() {
12549                        NodeFailureCode::SpawnDeadlineExceeded
12550                    } else {
12551                        NodeFailureCode::BackendBusy
12552                    },
12553                    "start did not commit before the bounded deadline; registration was removed",
12554                ));
12555            }
12556            sleep(Duration::from_millis(2)).await;
12557        }
12558    }
12559
12560    fn effective_spawn_runtime_policy(
12561        &self,
12562        runtime_policy: ProviderRuntimePolicy,
12563    ) -> ProviderRuntimePolicy {
12564        #[cfg(feature = "fixture")]
12565        let runtime_policy = if self.fixture_semantic_hook_policy {
12566            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12567                .expect("monitoring Hook fixture policy is internally valid")
12568        } else {
12569            runtime_policy
12570        };
12571        runtime_policy
12572    }
12573
12574    fn verify_harness_mcp_before_start(
12575        &self,
12576        prepared: Option<&PreparedHarnessMcpSpawn>,
12577    ) -> Result<(), NodeFailure> {
12578        prepared
12579            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12580            .transpose()
12581            .map(|_| ())
12582            .map_err(harness_mcp_failure)
12583    }
12584
12585    async fn rollback_spawn(
12586        &self,
12587        address: &SessionAddress,
12588        commit_timeout: Duration,
12589    ) -> Result<(), NodeFailure> {
12590        self.dispatch_bounded(
12591            ControlCommand::Remove {
12592                instance_id: address.session.instance_id,
12593            },
12594            commit_timeout,
12595        )
12596        .await?;
12597        self.wait_until_removed(address, commit_timeout).await?;
12598        if let Some(binding) = self.remove_binding(address) {
12599            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12600        }
12601        Ok(())
12602    }
12603
12604    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12605        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12606        self.dispatch_bounded(
12607            ControlCommand::Remove {
12608                instance_id: address.session.instance_id,
12609            },
12610            commit_timeout,
12611        )
12612            .await?;
12613        self.wait_until_removed(address, commit_timeout).await?;
12614        let removed = self.remove_binding(address);
12615        if let Some(binding) = removed.as_ref() {
12616            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12617        }
12618        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12619            let snapshot = self.managed_worktrees.lock()
12620                .unwrap_or_else(|poisoned| poisoned.into_inner())
12621                .release_session(
12622                    &lease_id,
12623                    self.incarnation_id,
12624                    address.session.instance_id,
12625                    address.session.generation,
12626                    unix_time_ms(),
12627                );
12628            if self.persist_state().is_err() {
12629                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12630            }
12631            if let Some(snapshot) = snapshot {
12632                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12633            }
12634            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12635        }
12636        Ok(())
12637    }
12638
12639    /// Polls until `address`'s session leaves the engine's own session map,
12640    /// or the bounded deadline expires.
12641    ///
12642    /// A `Remove` sent while the session is not yet in a terminal status
12643    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12644    /// once by the engine (`SessionStatus::allows_remove`) and
12645    /// then discarded -- nothing re-issues it on its own, so the removal
12646    /// that would satisfy this wait never arrives and the caller was
12647    /// guaranteed to time out even though the session settles microseconds
12648    /// later. So this re-dispatches on every poll tick: a session that
12649    /// settles mid-wait -- the common case right after a `Stop` -- still
12650    /// gets removed inside the same bounded deadline.
12651    ///
12652    /// It re-dispatches only once the status will ACCEPT the command. A
12653    /// rejected re-dispatch is not the harmless no-op this loop first
12654    /// assumed: every rejection publishes a `ControlEventKind::Command
12655    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12656    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12657    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12658    /// out to the harness and every attached client. Gated, the same
12659    /// teardown sends exactly one `Remove`, at the first tick the engine
12660    /// can act on it.
12661    ///
12662    /// The status is read from the snapshot this loop already takes, so
12663    /// the gate costs nothing extra. It races -- the status can move
12664    /// between the read and the dispatch -- and that is fine: a rare lost
12665    /// race is one rejection, and the next tick retries.
12666    ///
12667    /// Re-dispatch is separately rate-limited to
12668    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12669    /// leaves a second burst behind it. Once the session settles, every
12670    /// 2ms tick sent another `Remove` while the accepted one was still in
12671    /// flight, and each of those landed on an instance the engine had
12672    /// already dropped -- `agent instance … is not registered`, observed
12673    /// live as a tail of rejections immediately after the `session
12674    /// removed` line. The presence poll stays at 2ms so the wait still
12675    /// exits promptly; only the sending slows down.
12676    ///
12677    /// Rate-limited rather than once-only on purpose: the status gate
12678    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12679    /// others (a pending operation, an in-flight capability probe or
12680    /// history request) that clear on their own. A single shot would turn
12681    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12682    /// get their retries, just twenty of them across the deadline instead
12683    /// of a thousand.
12684    async fn wait_until_removed(
12685        &self,
12686        address: &SessionAddress,
12687        commit_timeout: Duration,
12688    ) -> Result<(), NodeFailure> {
12689        let deadline = Instant::now() + commit_timeout;
12690        let mut next_dispatch = Instant::now();
12691        loop {
12692            let snapshot = self.handle.snapshot();
12693            let status = snapshot
12694                .sessions
12695                .iter()
12696                .find(|session| session.instance_id == address.session.instance_id)
12697                .map(|session| session.status.clone());
12698            let Some(status) = status else {
12699                return Ok(());
12700            };
12701            let now = Instant::now();
12702            if now >= deadline {
12703                return Err(failure(
12704                    NodeFailureCode::BackendBusy,
12705                    "remove did not commit before the bounded deadline; session binding was retained",
12706                ));
12707            }
12708            if status.allows_remove() && now >= next_dispatch {
12709                let _ = self.dispatch(ControlCommand::Remove {
12710                    instance_id: address.session.instance_id,
12711                });
12712                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12713            }
12714            sleep(Duration::from_millis(2)).await;
12715        }
12716    }
12717
12718    fn resync(&self, after_sequence: u64) -> NodeResponse {
12719        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12720        let event_sequence = history.last_sequence;
12721        let oldest_available_sequence = history.replay_floor_sequence;
12722        let events = history
12723            .events
12724            .iter()
12725            .filter(|event| event.sequence > after_sequence)
12726            .cloned()
12727            .collect();
12728        drop(history);
12729        NodeResponse::Resync {
12730            event_sequence,
12731            oldest_available_sequence,
12732            snapshot: self.snapshot(),
12733            events,
12734        }
12735    }
12736}
12737
12738/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12739/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12740/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12741/// "name what crossed this boundary" reasoning, one layer upstream of it).
12742fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12743    match kind {
12744        AgentStreamChunkKindV1::Text { .. } => "text",
12745        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12746        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12747        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12748        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12749        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12750        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12751    }
12752}
12753
12754/// Serves this wire on the local endpoint, and -- when `call_home` names a
12755/// relay -- over an outbound connection to it as well.
12756///
12757/// The two live in one future rather than a fourth arm of `run`'s own
12758/// `select!` because they are one concern: this is where the node's wire
12759/// is served, and the direction a given connection was opened in does not
12760/// change what happens on it. It also means the existing graceful drain
12761/// below covers both without being duplicated.
12762///
12763/// Either side finishing ends the pair, which is correct: the listener
12764/// only returns on shutdown or a bind failure, and `call_home_loop` only
12765/// returns on shutdown -- a failed relay connection is retried inside it,
12766/// never propagated out.
12767async fn accept_connections(
12768    endpoint: &str,
12769    call_home: Option<std::net::SocketAddr>,
12770    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12771    shared: Arc<NodeShared>,
12772) -> Result<(), NodeServerError> {
12773    let mut connections = JoinSet::new();
12774    let result = match call_home {
12775        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12776        Some(relay) => {
12777            let listener =
12778                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12779            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12780            tokio::pin!(listener);
12781            tokio::pin!(dialler);
12782            tokio::select! {
12783                result = &mut listener => result,
12784                result = &mut dialler => result,
12785            }
12786        }
12787    };
12788    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12789    while !connections.is_empty() {
12790        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12791        if remaining.is_zero()
12792            || timeout(remaining, connections.join_next()).await.is_err()
12793        {
12794            break;
12795        }
12796    }
12797    connections.abort_all();
12798    while connections.join_next().await.is_some() {}
12799    result
12800}
12801
12802/// Backoff ladder for a relay that will not answer, capped rather than
12803/// unbounded: a node whose relay is down is not broken and should keep
12804/// trying forever, but it should not spend a core doing it.
12805const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12806
12807/// Dials the relay, serves this wire over the connection, and does it
12808/// again when the connection ends.
12809///
12810/// The node is the SERVER on the socket it just opened. That inversion is
12811/// the whole point: `serve_connection` is the same function the local
12812/// listener feeds, and it neither knows nor cares which end placed the
12813/// call. The one thing this direction adds is the preface -- the relay
12814/// accepted a socket and cannot know whose it is, so the node says its
12815/// name before the handshake it would otherwise begin with.
12816///
12817/// Never returns an error. A relay that is down, refusing, or speaking a
12818/// different protocol is a condition to retry, not a reason to take the
12819/// node's local endpoint down with it; the only way out is shutdown.
12820async fn call_home_loop(
12821    relay: std::net::SocketAddr,
12822    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12823    shared: Arc<NodeShared>,
12824) -> Result<(), NodeServerError> {
12825    let mut failures = 0_usize;
12826    loop {
12827        if shared.shutdown.load(Ordering::Acquire) {
12828            return Ok(());
12829        }
12830        let permit = tokio::select! {
12831            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12832                permit.map_err(|_| NodeServerError::PreauthClosed)?
12833            }
12834            _ = shared.shutdown_notify.notified() => return Ok(()),
12835        };
12836        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
12837            Ok(()) => {
12838                // A clean end is the relay hanging up, which it does on
12839                // its own restart. Reconnect immediately rather than
12840                // serving the backoff a peer that is plainly there.
12841                failures = 0;
12842                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12843            }
12844            Err(error) => {
12845                failures = failures.saturating_add(1);
12846                tracing::warn!(
12847                    node_id = %shared.node_id,
12848                    %relay,
12849                    cause = %error,
12850                    attempt = failures,
12851                    "call-home connection failed",
12852                );
12853                let backoff = CALL_HOME_BACKOFFS_MS
12854                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12855                tokio::select! {
12856                    _ = sleep(Duration::from_millis(backoff)) => {}
12857                    _ = shared.shutdown_notify.notified() => return Ok(()),
12858                }
12859            }
12860        }
12861    }
12862}
12863
12864/// One call-home attempt: connect, name this node, then serve.
12865async fn dial_relay_and_serve(
12866    relay: std::net::SocketAddr,
12867    identity: Option<&hatchery_enroll::NodeIdentity>,
12868    shared: Arc<NodeShared>,
12869    preauth_permit: OwnedSemaphorePermit,
12870) -> Result<(), NodeServerError> {
12871    let mut stream = tokio::net::TcpStream::connect(relay)
12872        .await
12873        .map_err(NodeServerError::CallHomeTransport)?;
12874    // Nagle off for the same reason the rest of this wire wants it off:
12875    // the traffic is small request/response frames, and coalescing them
12876    // adds latency to every one.
12877    let _ = stream.set_nodelay(true);
12878    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12879        .await
12880        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12881    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12882    let mut session_token = None;
12883    if let Some(identity) = identity {
12884        // An enrolled relay answers the announce with a signed-call-home challenge; a
12885        // token-mode relay sends its wire hello instead. Peek to tell them apart
12886        // without consuming either.
12887        let mut magic = [0_u8; 4];
12888        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
12889            .await
12890            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
12891            .map_err(NodeServerError::CallHomeTransport)?;
12892        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
12893            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
12894            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
12895                .await
12896                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
12897                .map_err(NodeServerError::CallHomeTransport)?;
12898            let (answer, token) = identity
12899                .answer_challenge(shared.node_id.as_str(), &frame)
12900                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12901            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
12902            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
12903            session_token = Some(token);
12904        }
12905    }
12906    serve_connection(stream, shared, session_token, preauth_permit).await
12907}
12908
12909async fn accept_connections_inner(
12910    endpoint: &str,
12911    shared: Arc<NodeShared>,
12912    connections: &mut JoinSet<Result<(), NodeServerError>>,
12913) -> Result<(), NodeServerError> {
12914    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12915    loop {
12916        while connections.try_join_next().is_some() {}
12917        if shared.shutdown.load(Ordering::Acquire) {
12918            return Ok(());
12919        }
12920        let preauth_permit = tokio::select! {
12921            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12922                permit.map_err(|_| NodeServerError::PreauthClosed)?
12923            }
12924            _ = shared.shutdown_notify.notified() => return Ok(()),
12925        };
12926        let server = tokio::select! {
12927            result = listener.accept() => result?,
12928            _ = shared.shutdown_notify.notified() => return Ok(()),
12929        };
12930        let connection_shared = Arc::clone(&shared);
12931        connections.spawn(async move {
12932            serve_connection(server, connection_shared, None, preauth_permit).await
12933        });
12934        if shared.shutdown.load(Ordering::Acquire) {
12935            return Ok(());
12936        }
12937    }
12938}
12939
12940/// Serves one connection, whichever end opened it.
12941///
12942/// Generic over the stream rather than typed to `LocalServerStream`
12943/// because a call-home connection arrives on a `TcpStream` the node itself
12944/// dialled. Nothing in the body ever depended on the concrete type -- it
12945/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
12946/// the whole of what welding "who dials" to "who serves" was costing.
12947async fn serve_connection<S>(
12948    mut pipe: S,
12949    shared: Arc<NodeShared>,
12950    session_token: Option<String>,
12951    preauth_permit: OwnedSemaphorePermit,
12952) -> Result<(), NodeServerError>
12953where
12954    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12955{
12956    // A signed call-home connection authenticates with its own per-connection token.
12957    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
12958    let hello_frame = timeout(
12959        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12960        read_json_frame_limited_body_timeout(
12961            &mut pipe,
12962            MAX_NODE_HELLO_FRAME_BYTES,
12963            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12964        ),
12965    )
12966    .await
12967    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12968    let ClientFrame::Hello(hello) = hello_frame else {
12969        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12970    };
12971    if hello.build_stamp != BUILD_STAMP {
12972        return Err(NodeServerError::Handshake(format!(
12973            "build stamp mismatch: local={BUILD_STAMP} remote={}",
12974            hello.build_stamp,
12975        )));
12976    }
12977    let compatibility = match hello.compatibility.as_ref() {
12978        Some(offer) => {
12979            let mut selected = node_compatibility_support(&shared)?
12980                .negotiate(offer)
12981                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12982            project_negotiated_provider_ids(&mut selected);
12983            Some(selected)
12984        }
12985        None => None,
12986    };
12987    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12988    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12989        (Some(offer), Some(selected)) => negotiated_auth_proof(
12990            auth_token,
12991            AuthDirection::Server,
12992            hello.role,
12993            &hello.client_nonce,
12994            &server_nonce,
12995            offer,
12996            selected,
12997        ),
12998        (None, None) => auth_proof(
12999            auth_token,
13000            AuthDirection::Server,
13001            hello.role,
13002            &hello.client_nonce,
13003            &server_nonce,
13004        ),
13005        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13006    }
13007    .map_err(NodeServerError::Authentication)?;
13008    write_json_frame_limited(
13009        &mut pipe,
13010        &ServerFrame::Challenge(ServerChallenge {
13011            build_stamp: BUILD_STAMP.to_owned(),
13012            server_nonce,
13013            server_proof,
13014            compatibility: compatibility.clone(),
13015        }),
13016        MAX_NODE_HELLO_FRAME_BYTES,
13017    )
13018    .await?;
13019    let authentication = timeout(
13020        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13021        read_json_frame_limited_body_timeout(
13022            &mut pipe,
13023            MAX_NODE_HELLO_FRAME_BYTES,
13024            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13025        ),
13026    )
13027    .await
13028    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13029    let ClientFrame::Authenticate(authentication) = authentication else {
13030        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13031    };
13032    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13033        (Some(offer), Some(selected)) => negotiated_auth_proof(
13034            auth_token,
13035            AuthDirection::Client,
13036            hello.role,
13037            &hello.client_nonce,
13038            &server_nonce,
13039            offer,
13040            selected,
13041        ),
13042        (None, None) => auth_proof(
13043            auth_token,
13044            AuthDirection::Client,
13045            hello.role,
13046            &hello.client_nonce,
13047            &server_nonce,
13048        ),
13049        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13050    }
13051    .map_err(NodeServerError::Authentication)?;
13052    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13053        return Err(NodeServerError::Handshake("access denied".to_owned()));
13054    }
13055    let selected_capabilities = compatibility
13056        .as_ref()
13057        .map(|selected| selected.capabilities.clone())
13058        .unwrap_or_default();
13059    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13060        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13061    });
13062    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13063        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13064    });
13065    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13066        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13067    });
13068    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13069        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13070    });
13071    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13072        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13073    });
13074    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13075        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13076    }) && selected_capabilities.iter().any(|capability| {
13077        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13078    });
13079    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13080        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13081    });
13082    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13083        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13084    });
13085    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13086        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13087    });
13088    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13089        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13090    });
13091    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13092        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13093    });
13094    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13095        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13096    });
13097    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13098        .try_acquire_owned()
13099        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13100    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13101    let _release_guard = ControllerReleaseGuard {
13102        shared: Arc::clone(&shared),
13103        connection_id,
13104    };
13105    drop(preauth_permit);
13106    let _authenticated_permit = authenticated_permit;
13107    let mut event_rx = shared.event_tx.subscribe();
13108    let mut terminal_event_rx = terminal_event_subscription(
13109        &shared,
13110        include_terminal_frame_events,
13111    );
13112    let mut agent_stream_event_rx = agent_stream_event_subscription(
13113        &shared,
13114        include_agent_stream_events,
13115    );
13116    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13117        &shared,
13118        include_harness_mcp_proxy,
13119    );
13120    write_json_frame(
13121        &mut pipe,
13122        &ServerFrame::Hello(NodeHello {
13123            build_stamp: BUILD_STAMP.to_owned(),
13124            incarnation_id: shared.incarnation_id,
13125            connection_id,
13126            role: hello.role,
13127            event_sequence: shared.current_sequence(),
13128            controller: shared.controller_state(),
13129            snapshot: snapshot_for_wire(
13130                &shared,
13131                include_provider_runtime_status,
13132                include_open_provider_ids,
13133                include_spawn_profiles,
13134                include_managed_worktrees,
13135                include_child_environment_profiles,
13136                include_session_bundles,
13137                include_history_context_packs,
13138                include_agent_progress,
13139                include_session_task_correlation,
13140            ),
13141            compatibility,
13142        }),
13143    )
13144    .await?;
13145    let (mut reader, mut writer) = tokio::io::split(pipe);
13146    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13147    let reader_task = tokio::spawn(async move {
13148        loop {
13149            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13150                &mut reader,
13151                MAX_NODE_CLIENT_FRAME_BYTES,
13152                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13153            )
13154            .await;
13155            let terminal = frame.is_err();
13156            if frame_tx.send(frame).await.is_err() || terminal {
13157                break;
13158            }
13159        }
13160    });
13161    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13162    let mut pending_events = Vec::new();
13163    let mut resync_required = false;
13164    let mut discard_events_through = 0_u64;
13165    loop {
13166        // One tick of the serve loop, whether or not it had anything to do.
13167        // Against `connection_events_sent` this separates "busy" from
13168        // "spinning": a loop turning over thousands of times a second while
13169        // sending nothing is not serving, it is burning a core.
13170        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13171        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13172            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13173        for _ in 0..durable_drain_budget {
13174            match event_rx.try_recv() {
13175                Ok(event) => queue_connection_event(
13176                    &mut pending_events,
13177                    event,
13178                    discard_events_through,
13179                ),
13180                Err(broadcast::error::TryRecvError::Empty) => break,
13181                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13182                    resync_required = true;
13183                    break;
13184                }
13185                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13186            }
13187        }
13188        if let Some(receiver) = terminal_event_rx.as_mut() {
13189            match receiver.try_recv() {
13190                Ok(events) => queue_connection_event_batch(
13191                    &mut pending_events,
13192                    &events,
13193                    discard_events_through,
13194                ),
13195                Err(broadcast::error::TryRecvError::Empty) => {}
13196                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13197                    resync_required = true;
13198                }
13199                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13200            }
13201        }
13202        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13203            // Loops up to the same remaining-room budget the durable
13204            // `event_rx` drain above uses, rather than one `try_recv` per
13205            // tick: each successful receive here is itself only ONE
13206            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13207            // sends `vec![envelope]`, never more), so a single-shot drain
13208            // caps this connection's agent-stream throughput at one chunk
13209            // per outer-loop tick regardless of how many are already queued
13210            // -- exactly the kind of self-inflicted stall this crate's own
13211            // durable-event budget exists to avoid.
13212            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13213                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13214            for _ in 0..agent_stream_drain_budget {
13215                match receiver.try_recv() {
13216                    Ok(events) => queue_connection_event_batch(
13217                        &mut pending_events,
13218                        &events,
13219                        discard_events_through,
13220                    ),
13221                    Err(broadcast::error::TryRecvError::Empty) => break,
13222                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13223                        tracing::warn!(
13224                            connection_id,
13225                            dropped,
13226                            "agent stream event subscription lagged; forcing a resync",
13227                        );
13228                        resync_required = true;
13229                        break;
13230                    }
13231                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13232                }
13233            }
13234        }
13235        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13236            // Mirrors the agent-stream drain immediately above: each
13237            // successful receive is one single-envelope batch
13238            // (`publish_transient` always sends `vec![envelope]`), so this
13239            // loops up to the same remaining-room budget rather than
13240            // stopping after one `try_recv`.
13241            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13242                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13243            for _ in 0..harness_mcp_proxy_drain_budget {
13244                match receiver.try_recv() {
13245                    Ok(events) => queue_connection_event_batch(
13246                        &mut pending_events,
13247                        &events,
13248                        discard_events_through,
13249                    ),
13250                    Err(broadcast::error::TryRecvError::Empty) => break,
13251                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13252                        tracing::warn!(
13253                            connection_id,
13254                            dropped,
13255                            "harness MCP proxy event subscription lagged; forcing a resync",
13256                        );
13257                        resync_required = true;
13258                        break;
13259                    }
13260                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13261                }
13262            }
13263        }
13264        if resync_required {
13265            let event = resync_required_event(&shared);
13266            apply_connection_resync_watermark(
13267                &mut pending_events,
13268                &mut discard_events_through,
13269                event.sequence,
13270            );
13271            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13272            resync_required = false;
13273            continue;
13274        }
13275        if !pending_events.is_empty() {
13276            pending_events.sort_unstable_by_key(|event| event.sequence);
13277            let burst_len = connection_event_burst_len(pending_events.len());
13278            shared
13279                .connection_events_sent
13280                .fetch_add(burst_len as u64, Ordering::Relaxed);
13281            for event in pending_events.drain(..burst_len) {
13282                let event = if include_managed_worktrees {
13283                    Some(event)
13284                } else {
13285                    project_event_without_managed_worktrees(event)
13286                };
13287                let event = event.and_then(|event| if include_open_provider_ids {
13288                    Some(event)
13289                } else {
13290                    project_event_legacy_provider_ids(&shared, event)
13291                });
13292                let event = event.map(|event| if include_child_environment_profiles {
13293                    event
13294                } else {
13295                    project_event_without_child_environment_profile(event)
13296                });
13297                let event = event.map(|event| if include_session_bundles {
13298                    event
13299                } else {
13300                    project_event_without_session_bundle(event)
13301                });
13302                let event = event.map(|event| {
13303                    project_event_history_for_wire(event, include_history_context_packs)
13304                });
13305                let event = event.map(|event| if include_history_context_packs {
13306                    event
13307                } else {
13308                    project_event_without_context_pack(event)
13309                });
13310                let event = event.map(|event| if include_session_task_correlation {
13311                    event
13312                } else {
13313                    project_event_without_session_task_binding(event)
13314                });
13315                let exact_harness_controller = hello.role == ClientRole::Operator
13316                    && shared.controller_state().is_some_and(|controller| {
13317                        controller.connection_id == connection_id
13318                    });
13319                let event = event.and_then(|event| {
13320                    if include_harness_mcp_proxy && exact_harness_controller {
13321                        Some(event)
13322                    } else {
13323                        project_event_without_harness_mcp_proxy(event)
13324                    }
13325                });
13326                if let Some(event) = event {
13327                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13328                }
13329            }
13330        }
13331        tokio::select! {
13332            biased;
13333            frame = frame_rx.recv() => {
13334                let frame = match frame {
13335                    Some(frame) => frame,
13336                    None => break,
13337                };
13338                let frame = match frame {
13339                    Ok(frame) => frame,
13340                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13341                    Err(error) => return Err(error.into()),
13342                };
13343                let ClientFrame::Request(request) = frame else {
13344                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13345                };
13346                #[cfg(feature = "fixture")]
13347                let probe_spawn_managed_worktree_v2 = matches!(
13348                    &request.request,
13349                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13350                );
13351                let requires_open_provider_ids =
13352                    request_requires_open_provider_ids(&shared, &request.request);
13353                let requires_child_environment_profile =
13354                    request_requires_child_environment_profile(&shared, &request.request);
13355                let requires_session_bundle =
13356                    request_requires_session_bundle(&shared, &request.request);
13357                let requires_history_context_pack =
13358                    request_requires_history_context_pack(&shared, &request.request);
13359                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13360                    ResponseEnvelope {
13361                        request_id: request.request_id,
13362                        result: Err(failure(
13363                            NodeFailureCode::UnsupportedCapability,
13364                            "open provider IDs were not negotiated",
13365                        )),
13366                    }
13367                } else if requires_child_environment_profile
13368                    && !include_child_environment_profiles
13369                {
13370                    ResponseEnvelope {
13371                        request_id: request.request_id,
13372                        result: Err(failure(
13373                            NodeFailureCode::UnsupportedCapability,
13374                            "child environment profiles were not negotiated",
13375                        )),
13376                    }
13377                } else if requires_session_bundle && !include_session_bundles {
13378                    ResponseEnvelope {
13379                        request_id: request.request_id,
13380                        result: Err(failure(
13381                            NodeFailureCode::UnsupportedCapability,
13382                            "session bundles were not negotiated",
13383                        )),
13384                    }
13385                } else if requires_history_context_pack && !include_history_context_packs {
13386                    ResponseEnvelope {
13387                        request_id: request.request_id,
13388                        result: Err(failure(
13389                            NodeFailureCode::UnsupportedCapability,
13390                            "history context packs were not negotiated",
13391                        )),
13392                    }
13393                } else if request_uses_unnegotiated_capability(
13394                    &request.request,
13395                    &selected_capabilities,
13396                )
13397                {
13398                    ResponseEnvelope {
13399                        request_id: request.request_id,
13400                        result: Err(failure(
13401                            NodeFailureCode::UnsupportedCapability,
13402                            "request capability was not negotiated",
13403                        )),
13404                    }
13405                } else {
13406                    process_request(&shared, connection_id, hello.role, request).await
13407                };
13408                #[cfg(feature = "fixture")]
13409                if probe_spawn_managed_worktree_v2 {
13410                    shared
13411                        .fixture_spawn_managed_worktree_v2_failure_probe
13412                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13413                }
13414                if !include_provider_runtime_status {
13415                    clear_response_provider_runtime_status(&mut reply);
13416                }
13417                if !include_managed_worktrees {
13418                    project_response_without_managed_worktrees(&mut reply);
13419                }
13420                if !include_open_provider_ids {
13421                    project_response_legacy_provider_ids(&shared, &mut reply);
13422                }
13423                if !include_child_environment_profiles {
13424                    project_response_without_child_environment_profile(&mut reply);
13425                }
13426                if !include_session_bundles {
13427                    project_response_without_session_bundle(&mut reply);
13428                }
13429                project_response_history_for_wire(
13430                    &mut reply,
13431                    include_history_context_packs,
13432                );
13433                if !include_history_context_packs {
13434                    project_response_without_context_pack(&mut reply);
13435                }
13436                if !include_agent_progress {
13437                    project_response_without_agent_progress(&mut reply);
13438                }
13439                if !include_session_task_correlation {
13440                    project_response_without_session_task_binding(&mut reply);
13441                }
13442                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13443            }
13444            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13445            event = event_rx.recv() => {
13446                match event {
13447                    Ok(event) => queue_connection_event(
13448                        &mut pending_events,
13449                        event,
13450                        discard_events_through,
13451                    ),
13452                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13453                    Err(broadcast::error::RecvError::Closed) => break,
13454                }
13455            }
13456            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13457                match events {
13458                    Ok(events) => queue_connection_event_batch(
13459                        &mut pending_events,
13460                        &events,
13461                        discard_events_through,
13462                    ),
13463                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13464                    Err(broadcast::error::RecvError::Closed) => break,
13465                }
13466            }
13467            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13468                match events {
13469                    Ok(events) => queue_connection_event_batch(
13470                        &mut pending_events,
13471                        &events,
13472                        discard_events_through,
13473                    ),
13474                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13475                    Err(broadcast::error::RecvError::Closed) => break,
13476                }
13477            }
13478            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13479                match events {
13480                    Ok(events) => queue_connection_event_batch(
13481                        &mut pending_events,
13482                        &events,
13483                        discard_events_through,
13484                    ),
13485                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13486                    Err(broadcast::error::RecvError::Closed) => break,
13487                }
13488            }
13489            _ = shared.shutdown_notify.notified() => break,
13490        }
13491    }
13492    Ok(())
13493}
13494
13495async fn receive_terminal_event_batch(
13496    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13497) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13498    match receiver {
13499        Some(receiver) => receiver.recv().await,
13500        None => std::future::pending().await,
13501    }
13502}
13503
13504fn terminal_event_subscription(
13505    shared: &NodeShared,
13506    enabled: bool,
13507) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13508    enabled.then(|| shared.terminal_event_tx.subscribe())
13509}
13510
13511/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13512/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13513/// this needs a dedicated channel rather than a flag on the terminal one.
13514fn agent_stream_event_subscription(
13515    shared: &NodeShared,
13516    enabled: bool,
13517) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13518    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13519}
13520
13521/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13522/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13523/// own doc for why this needs a dedicated channel rather than a ride on
13524/// the durable `event_tx`.
13525fn harness_mcp_proxy_event_subscription(
13526    shared: &NodeShared,
13527    enabled: bool,
13528) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13529    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13530}
13531
13532fn queue_connection_event(
13533    pending: &mut Vec<NodeEventEnvelope>,
13534    event: NodeEventEnvelope,
13535    discard_through: u64,
13536) {
13537    if event.sequence <= discard_through
13538        && !event.event.requires_harness_mcp_proxy_capability()
13539    {
13540        return;
13541    }
13542    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13543        if let Some(index) = pending.iter().position(|current| {
13544            matches!(
13545                &current.event,
13546                NodeEvent::TerminalFrame {
13547                    address: current_address,
13548                    ..
13549                } if current_address == address
13550            )
13551        }) {
13552            pending.remove(index);
13553        }
13554    }
13555    pending.push(event);
13556}
13557
13558fn project_event_without_harness_mcp_proxy(
13559    envelope: NodeEventEnvelope,
13560) -> Option<NodeEventEnvelope> {
13561    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13562}
13563
13564fn queue_connection_event_batch(
13565    pending: &mut Vec<NodeEventEnvelope>,
13566    events: &[NodeEventEnvelope],
13567    discard_through: u64,
13568) {
13569    for event in events {
13570        queue_connection_event(pending, event.clone(), discard_through);
13571    }
13572}
13573
13574fn apply_connection_resync_watermark(
13575    pending: &mut Vec<NodeEventEnvelope>,
13576    discard_through: &mut u64,
13577    marker_sequence: u64,
13578) {
13579    *discard_through = (*discard_through).max(marker_sequence);
13580    pending.retain(|event| event.sequence > *discard_through);
13581}
13582
13583fn connection_event_burst_len(pending_len: usize) -> usize {
13584    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13585}
13586
13587fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13588    let history = shared
13589        .history
13590        .lock()
13591        .unwrap_or_else(|poisoned| poisoned.into_inner());
13592    let sequence = history.last_sequence;
13593    let oldest_available_sequence = history.replay_floor_sequence;
13594    NodeEventEnvelope {
13595        sequence,
13596        event: NodeEvent::ResyncRequired {
13597            oldest_available_sequence,
13598        },
13599    }
13600}
13601
13602fn node_compatibility_support(
13603    shared: &NodeShared,
13604) -> Result<NodeCompatibilitySupport, NodeServerError> {
13605    let mut support = node_compatibility_support_for_manifest(
13606        &shared.provider_contracts,
13607        &shared.provider_adapter_contracts,
13608    )?;
13609    if shared
13610        .delivery_store
13611        .lock()
13612        .unwrap_or_else(|poisoned| poisoned.into_inner())
13613        .is_some()
13614    {
13615        support.capabilities.push(
13616            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13617                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13618        );
13619    }
13620    if shared.harness_mcp_registry.is_some() {
13621        support.capabilities.push(
13622            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13623                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13624        );
13625    }
13626    Ok(support)
13627}
13628
13629fn node_compatibility_support_for_manifest(
13630    provider_contracts: &[ProviderContractSupport],
13631    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13632) -> Result<NodeCompatibilitySupport, NodeServerError> {
13633    Ok(NodeCompatibilitySupport {
13634        build_stamp: BUILD_STAMP.to_owned(),
13635        capabilities: baseline_capabilities()?,
13636        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13637        path_semantics: platform::path_semantics(),
13638        local_transport: platform::local_transport(),
13639        state_schema: StateSchemaSupport {
13640            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13641                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13642        },
13643        provider_contracts: provider_contracts.to_vec(),
13644        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13645    })
13646}
13647
13648fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13649    let capabilities = [
13650        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13651        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13652        NODE_REPOSITORY_PATH_CAPABILITY,
13653        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13654        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13655        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13656        NODE_GIT_READ_CAPABILITY,
13657        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13658        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13659        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13660        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13661        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13662        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13663        NODE_ACP_CONTROL_CAPABILITY,
13664        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13665        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13666        NODE_WORKTREE_SELECTION_CAPABILITY,
13667        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13668        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13669        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13670        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13671        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13672        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13673        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13674        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13675        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13676        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13677        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13678        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13679    ]
13680        .into_iter()
13681        .map(|capability| {
13682            CapabilityId::new(capability)
13683                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13684        })
13685        .collect::<Result<Vec<_>, _>>()?;
13686    #[cfg(windows)]
13687    let capabilities = {
13688        let mut capabilities = capabilities;
13689        capabilities.push(
13690            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13691                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13692        );
13693        capabilities
13694    };
13695    Ok(capabilities)
13696}
13697
13698fn request_uses_unnegotiated_capability(
13699    request: &NodeRequest,
13700    selected_capabilities: &[CapabilityId],
13701) -> bool {
13702    let selected = |required: &str| {
13703        selected_capabilities
13704            .iter()
13705            .any(|capability| capability.as_str() == required)
13706    };
13707    request
13708        .required_capability()
13709        .is_some_and(|required| !selected(required))
13710        || (request.requires_worktree_selection_capability()
13711            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13712        || (request.requires_spawn_spec_defaults_overrides_capability()
13713            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13714        || (request.requires_spawn_profile_revision_capability()
13715            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13716        || (request.requires_child_environment_profile_capability()
13717            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13718        || (request.requires_session_bundle_materialization_capability()
13719            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13720        || (request.requires_history_context_pack_capability()
13721            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13722}
13723
13724fn snapshot_for_wire(
13725    shared: &NodeShared,
13726    include_provider_runtime_status: bool,
13727    include_open_provider_ids: bool,
13728    include_spawn_profiles: bool,
13729    include_managed_worktrees: bool,
13730    include_child_environment_profiles: bool,
13731    include_session_bundles: bool,
13732    include_history_context_packs: bool,
13733    include_agent_progress: bool,
13734    include_session_task_correlation: bool,
13735) -> NodeSnapshot {
13736    let mut snapshot = shared.snapshot();
13737    if !include_provider_runtime_status {
13738        snapshot.provider_runtime_statuses.clear();
13739    }
13740    if !include_open_provider_ids {
13741        project_snapshot_legacy_provider_ids(&mut snapshot);
13742    }
13743    if !include_managed_worktrees {
13744        snapshot.managed_worktrees.clear();
13745        for workspace in &mut snapshot.workspaces {
13746            workspace.managed_worktree_profiles = None;
13747        }
13748    }
13749    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13750        if !include_spawn_profiles {
13751            inventory.spawn_profiles = None;
13752        }
13753        if !include_session_bundles {
13754            inventory.bundles = None;
13755        }
13756        if inventory.spawn_profiles.is_none()
13757            && inventory.bundles.is_none()
13758            && inventory.network_allowlists.is_none()
13759        {
13760            snapshot.launch_inventory = None;
13761        }
13762    }
13763    if !include_child_environment_profiles {
13764        clear_snapshot_child_environment_profiles(&mut snapshot);
13765    }
13766    if !include_session_bundles {
13767        clear_snapshot_session_bundles(&mut snapshot);
13768    }
13769    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13770    if !include_history_context_packs {
13771        clear_snapshot_context_packs(&mut snapshot);
13772    }
13773    if !include_agent_progress {
13774        snapshot.agent_progress.clear();
13775    }
13776    if !include_session_task_correlation {
13777        clear_snapshot_session_task_bindings(&mut snapshot);
13778    }
13779    snapshot
13780}
13781
13782fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13783    for record in &mut snapshot.session_records {
13784        record.task_binding = None;
13785    }
13786}
13787
13788fn project_event_without_session_task_binding(
13789    mut envelope: NodeEventEnvelope,
13790) -> NodeEventEnvelope {
13791    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13792        record.task_binding = None;
13793    }
13794    envelope
13795}
13796
13797fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13798    let Ok(response) = reply.result.as_mut() else { return };
13799    match response {
13800        NodeResponse::Snapshot { snapshot, .. } => {
13801            clear_snapshot_session_task_bindings(snapshot);
13802        }
13803        NodeResponse::Resync { snapshot, events, .. } => {
13804            clear_snapshot_session_task_bindings(snapshot);
13805            for event in events {
13806                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13807                    record.task_binding = None;
13808                }
13809            }
13810        }
13811        NodeResponse::SessionRecordUpdated { record }
13812        | NodeResponse::ProviderSessionIndexed { record }
13813        | NodeResponse::NativeSessionIndexed { record, .. }
13814        | NodeResponse::SessionRecordResumed { record, .. } => {
13815            record.task_binding = None;
13816        }
13817        _ => {}
13818    }
13819}
13820
13821fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13822    let Ok(response) = reply.result.as_mut() else { return };
13823    match response {
13824        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13825            snapshot.agent_progress.clear();
13826        }
13827        _ => {}
13828    }
13829}
13830
13831fn project_event_without_managed_worktrees(
13832    mut envelope: NodeEventEnvelope,
13833) -> Option<NodeEventEnvelope> {
13834    if matches!(
13835        envelope.event,
13836        NodeEvent::ManagedWorktreeUpserted { .. }
13837            | NodeEvent::ManagedWorktreeRemoved { .. }
13838    ) {
13839        return None;
13840    }
13841    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13842        workspace.managed_worktree_profiles = None;
13843    }
13844    Some(envelope)
13845}
13846
13847fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13848    if matches!(
13849        reply.result,
13850        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13851            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13852    ) {
13853        reply.result = Err(failure(
13854            NodeFailureCode::UnsupportedCapability,
13855            "managed worktree capability was not negotiated",
13856        ));
13857        return;
13858    }
13859    let Ok(response) = reply.result.as_mut() else { return };
13860    match response {
13861        NodeResponse::Snapshot { snapshot, .. } => {
13862            snapshot.managed_worktrees.clear();
13863            for workspace in &mut snapshot.workspaces {
13864                workspace.managed_worktree_profiles = None;
13865            }
13866        }
13867        NodeResponse::Resync { snapshot, events, .. } => {
13868            snapshot.managed_worktrees.clear();
13869            for workspace in &mut snapshot.workspaces {
13870                workspace.managed_worktree_profiles = None;
13871            }
13872            *events = std::mem::take(events)
13873                .into_iter()
13874                .filter_map(project_event_without_managed_worktrees)
13875                .collect();
13876        }
13877        NodeResponse::WorkspaceRegistered { workspace }
13878        | NodeResponse::StandaloneWorkspaceCreated { workspace }
13879        | NodeResponse::WorktreeCreated { workspace, .. } => {
13880            workspace.managed_worktree_profiles = None;
13881        }
13882        NodeResponse::WorkspaceInspected { inspection } => {
13883            inspection.git.managed_worktree = None;
13884        }
13885        _ => {}
13886    }
13887}
13888
13889fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13890    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13891        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13892    });
13893    if includes_open_provider_ids {
13894        return;
13895    }
13896    compatibility
13897        .provider_contracts
13898        .retain(|contract| provider_id_is_legacy(&contract.provider));
13899    compatibility
13900        .provider_adapter_contracts
13901        .retain(|contract| provider_id_is_legacy(&contract.provider));
13902}
13903
13904fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13905    if let Some(spec) = match request {
13906        NodeRequest::SpawnSpec { spec }
13907        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13908        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13909        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13910        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13911        _ => None,
13912    } {
13913        return shared
13914            .resolve_spawn_spec(spec)
13915            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13916    }
13917    request_requires_open_provider_ids_with(
13918        request,
13919        |session| shared.validate_address(session).ok(),
13920        |record_id| shared.record(record_id).ok().map(|record| record.provider),
13921    )
13922}
13923
13924fn request_requires_child_environment_profile(
13925    shared: &NodeShared,
13926    request: &NodeRequest,
13927) -> bool {
13928    if let Some(spec) = match request {
13929        NodeRequest::SpawnSpec { spec }
13930        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13931        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13932        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13933        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13934        _ => None,
13935    } {
13936        return shared
13937            .resolve_spawn_spec(spec)
13938            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13939    }
13940    match request {
13941        NodeRequest::Resume { session, .. }
13942        | NodeRequest::Prompt { session, .. }
13943        | NodeRequest::Paste { session, .. }
13944        | NodeRequest::Input { session, .. }
13945        | NodeRequest::TerminalBytes { session, .. }
13946        | NodeRequest::TerminalControl { session, .. }
13947        | NodeRequest::Resize { session, .. }
13948        | NodeRequest::Interrupt { session }
13949        | NodeRequest::Stop { session, .. }
13950        | NodeRequest::Remove { session }
13951        | NodeRequest::ResolveInteraction { session, .. }
13952        | NodeRequest::SetSessionMode { session, .. }
13953        | NodeRequest::SetSessionConfigOption { session, .. }
13954        | NodeRequest::SetSessionModel { session, .. }
13955        | NodeRequest::DiscoverHistory { session, .. }
13956        | NodeRequest::LoadHistory { session, .. }
13957        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13958        | NodeRequest::ExportContextPack { session } => shared
13959            .session_bindings
13960            .lock()
13961            .unwrap_or_else(|poisoned| poisoned.into_inner())
13962            .get(&session.session.instance_id)
13963            .filter(|binding| {
13964                binding.workspace_id == session.workspace_id
13965                    && binding.generation == session.session.generation
13966            })
13967            .map_or(true, |binding| binding.environment_profile.is_some()),
13968        NodeRequest::RenameSessionRecord { record_id, .. }
13969        | NodeRequest::SetSessionTask { record_id, .. }
13970        | NodeRequest::ResumeSessionRecord { record_id, .. }
13971        | NodeRequest::ForgetSessionRecord { record_id } => shared
13972            .record(record_id)
13973            .map_or(true, |record| record.environment_profile.is_some()),
13974        NodeRequest::ArmHarnessMcpReservation { .. }
13975        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13976        | NodeRequest::ActivateHarnessMcpReservation { .. }
13977        | NodeRequest::AbortHarnessMcpReservation { .. }
13978        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13979        | NodeRequest::RejectHarnessMcpCall { .. }
13980        | NodeRequest::Snapshot
13981        | NodeRequest::Resync { .. }
13982        | NodeRequest::BeginDeliveryStage { .. }
13983        | NodeRequest::PutDeliveryBlobChunk { .. }
13984        | NodeRequest::CommitDeliveryStage { .. }
13985        | NodeRequest::AbortDeliveryStage { .. }
13986        | NodeRequest::BrowseHostDirectories { .. }
13987        | NodeRequest::InspectWorkspace { .. }
13988        | NodeRequest::ReadWorkspaceFile { .. }
13989        | NodeRequest::WriteWorkspaceFile { .. }
13990        | NodeRequest::CreateWorkspaceFile { .. }
13991        | NodeRequest::CreateWorkspaceDirectory { .. }
13992        | NodeRequest::ReadGitHistory { .. }
13993        | NodeRequest::ReadGitDiff { .. }
13994        | NodeRequest::AcquireController { .. }
13995        | NodeRequest::ReleaseController
13996        | NodeRequest::Spawn { .. }
13997        | NodeRequest::SpawnSpec { .. }
13998        | NodeRequest::RegisterWorkspace { .. }
13999        | NodeRequest::CreateStandaloneWorkspace { .. }
14000        | NodeRequest::UnregisterWorkspace { .. }
14001        | NodeRequest::CreateWorktree { .. }
14002        | NodeRequest::RemoveWorktree { .. }
14003        | NodeRequest::SpawnManagedWorktree { .. }
14004        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14005        | NodeRequest::CleanupManagedWorktree { .. }
14006        | NodeRequest::IndexProviderSession { .. }
14007        | NodeRequest::IndexNativeSession { .. }
14008        | NodeRequest::CatalogNativeSessions { .. }
14009        | NodeRequest::PageNativeSessions { .. }
14010        | NodeRequest::PreviewNativeSession { .. }
14011        | NodeRequest::PreviewSessionRecord { .. }
14012        | NodeRequest::ForgetContextPack { .. }
14013        | NodeRequest::ResolveDurableContextPack { .. }
14014        | NodeRequest::ReadContextPack { .. }
14015        | NodeRequest::Shutdown => false,
14016    }
14017}
14018
14019fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14020    if let Some(spec) = match request {
14021        NodeRequest::SpawnSpec { spec }
14022        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14023        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14024        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14025        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14026        _ => None,
14027    } {
14028        return shared
14029            .resolve_spawn_spec(spec)
14030            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14031    }
14032    match request {
14033        NodeRequest::Resume { session, .. }
14034        | NodeRequest::Prompt { session, .. }
14035        | NodeRequest::Paste { session, .. }
14036        | NodeRequest::Input { session, .. }
14037        | NodeRequest::TerminalBytes { session, .. }
14038        | NodeRequest::TerminalControl { session, .. }
14039        | NodeRequest::Resize { session, .. }
14040        | NodeRequest::Interrupt { session }
14041        | NodeRequest::Stop { session, .. }
14042        | NodeRequest::Remove { session }
14043        | NodeRequest::ResolveInteraction { session, .. }
14044        | NodeRequest::SetSessionMode { session, .. }
14045        | NodeRequest::SetSessionConfigOption { session, .. }
14046        | NodeRequest::SetSessionModel { session, .. }
14047        | NodeRequest::DiscoverHistory { session, .. }
14048        | NodeRequest::LoadHistory { session, .. }
14049        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14050        | NodeRequest::ExportContextPack { session } => shared
14051            .session_bindings
14052            .lock()
14053            .unwrap_or_else(|poisoned| poisoned.into_inner())
14054            .get(&session.session.instance_id)
14055            .filter(|binding| {
14056                binding.workspace_id == session.workspace_id
14057                    && binding.generation == session.session.generation
14058            })
14059            .map_or(true, |binding| binding.bundle.is_some()),
14060        NodeRequest::RenameSessionRecord { record_id, .. }
14061        | NodeRequest::SetSessionTask { record_id, .. }
14062        | NodeRequest::ResumeSessionRecord { record_id, .. }
14063        | NodeRequest::ForgetSessionRecord { record_id } => shared
14064            .record(record_id)
14065            .map_or(true, |record| record.bundle.is_some()),
14066        NodeRequest::ArmHarnessMcpReservation { .. }
14067        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14068        | NodeRequest::ActivateHarnessMcpReservation { .. }
14069        | NodeRequest::AbortHarnessMcpReservation { .. }
14070        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14071        | NodeRequest::RejectHarnessMcpCall { .. }
14072        | NodeRequest::Snapshot
14073        | NodeRequest::Resync { .. }
14074        | NodeRequest::BeginDeliveryStage { .. }
14075        | NodeRequest::PutDeliveryBlobChunk { .. }
14076        | NodeRequest::CommitDeliveryStage { .. }
14077        | NodeRequest::AbortDeliveryStage { .. }
14078        | NodeRequest::BrowseHostDirectories { .. }
14079        | NodeRequest::InspectWorkspace { .. }
14080        | NodeRequest::ReadWorkspaceFile { .. }
14081        | NodeRequest::WriteWorkspaceFile { .. }
14082        | NodeRequest::CreateWorkspaceFile { .. }
14083        | NodeRequest::CreateWorkspaceDirectory { .. }
14084        | NodeRequest::ReadGitHistory { .. }
14085        | NodeRequest::ReadGitDiff { .. }
14086        | NodeRequest::AcquireController { .. }
14087        | NodeRequest::ReleaseController
14088        | NodeRequest::Spawn { .. }
14089        | NodeRequest::SpawnSpec { .. }
14090        | NodeRequest::RegisterWorkspace { .. }
14091        | NodeRequest::CreateStandaloneWorkspace { .. }
14092        | NodeRequest::UnregisterWorkspace { .. }
14093        | NodeRequest::CreateWorktree { .. }
14094        | NodeRequest::RemoveWorktree { .. }
14095        | NodeRequest::SpawnManagedWorktree { .. }
14096        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14097        | NodeRequest::CleanupManagedWorktree { .. }
14098        | NodeRequest::IndexProviderSession { .. }
14099        | NodeRequest::IndexNativeSession { .. }
14100        | NodeRequest::CatalogNativeSessions { .. }
14101        | NodeRequest::PageNativeSessions { .. }
14102        | NodeRequest::PreviewNativeSession { .. }
14103        | NodeRequest::PreviewSessionRecord { .. }
14104        | NodeRequest::ForgetContextPack { .. }
14105        | NodeRequest::ResolveDurableContextPack { .. }
14106        | NodeRequest::ReadContextPack { .. }
14107        | NodeRequest::Shutdown => false,
14108    }
14109}
14110
14111fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14112    if request.requires_history_context_pack_capability() {
14113        return true;
14114    }
14115    if let Some(spec) = match request {
14116        NodeRequest::SpawnSpec { spec }
14117        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14118        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14119        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14120        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14121        _ => None,
14122    } {
14123        return shared
14124            .resolve_spawn_spec(spec)
14125            .map_or(true, |resolved| resolved.context_id.is_some());
14126    }
14127    match request {
14128        NodeRequest::Resume { session, .. }
14129        | NodeRequest::Prompt { session, .. }
14130        | NodeRequest::Paste { session, .. }
14131        | NodeRequest::Input { session, .. }
14132        | NodeRequest::TerminalBytes { session, .. }
14133        | NodeRequest::TerminalControl { session, .. }
14134        | NodeRequest::Resize { session, .. }
14135        | NodeRequest::Interrupt { session }
14136        | NodeRequest::Stop { session, .. }
14137        | NodeRequest::Remove { session }
14138        | NodeRequest::ResolveInteraction { session, .. }
14139        | NodeRequest::SetSessionMode { session, .. }
14140        | NodeRequest::SetSessionConfigOption { session, .. }
14141        | NodeRequest::SetSessionModel { session, .. } => shared
14142            .session_bindings
14143            .lock()
14144            .unwrap_or_else(|poisoned| poisoned.into_inner())
14145            .get(&session.session.instance_id)
14146            .filter(|binding| {
14147                binding.workspace_id == session.workspace_id
14148                    && binding.generation == session.session.generation
14149            })
14150            .map_or(true, |binding| binding.context.is_some()),
14151        NodeRequest::RenameSessionRecord { record_id, .. }
14152        | NodeRequest::SetSessionTask { record_id, .. }
14153        | NodeRequest::ResumeSessionRecord { record_id, .. }
14154        | NodeRequest::ForgetSessionRecord { record_id } => shared
14155            .record(record_id)
14156            .map_or(true, |record| record.context.is_some()),
14157        NodeRequest::ArmHarnessMcpReservation { .. }
14158        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14159        | NodeRequest::ActivateHarnessMcpReservation { .. }
14160        | NodeRequest::AbortHarnessMcpReservation { .. }
14161        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14162        | NodeRequest::RejectHarnessMcpCall { .. }
14163        | NodeRequest::Snapshot
14164        | NodeRequest::Resync { .. }
14165        | NodeRequest::BeginDeliveryStage { .. }
14166        | NodeRequest::PutDeliveryBlobChunk { .. }
14167        | NodeRequest::CommitDeliveryStage { .. }
14168        | NodeRequest::AbortDeliveryStage { .. }
14169        | NodeRequest::BrowseHostDirectories { .. }
14170        | NodeRequest::InspectWorkspace { .. }
14171        | NodeRequest::ReadWorkspaceFile { .. }
14172        | NodeRequest::WriteWorkspaceFile { .. }
14173        | NodeRequest::CreateWorkspaceFile { .. }
14174        | NodeRequest::CreateWorkspaceDirectory { .. }
14175        | NodeRequest::ReadGitHistory { .. }
14176        | NodeRequest::ReadGitDiff { .. }
14177        | NodeRequest::AcquireController { .. }
14178        | NodeRequest::ReleaseController
14179        | NodeRequest::Spawn { .. }
14180        | NodeRequest::SpawnSpec { .. }
14181        | NodeRequest::RegisterWorkspace { .. }
14182        | NodeRequest::CreateStandaloneWorkspace { .. }
14183        | NodeRequest::UnregisterWorkspace { .. }
14184        | NodeRequest::CreateWorktree { .. }
14185        | NodeRequest::RemoveWorktree { .. }
14186        | NodeRequest::SpawnManagedWorktree { .. }
14187        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14188        | NodeRequest::CleanupManagedWorktree { .. }
14189        | NodeRequest::IndexProviderSession { .. }
14190        | NodeRequest::IndexNativeSession { .. }
14191        | NodeRequest::CatalogNativeSessions { .. }
14192        | NodeRequest::PageNativeSessions { .. }
14193        | NodeRequest::PreviewNativeSession { .. }
14194        | NodeRequest::PreviewSessionRecord { .. }
14195        | NodeRequest::DiscoverHistory { .. }
14196        | NodeRequest::LoadHistory { .. }
14197        | NodeRequest::ExportContextPackForSessionRecord { .. }
14198        | NodeRequest::ExportContextPack { .. }
14199        | NodeRequest::ForgetContextPack { .. }
14200        | NodeRequest::ResolveDurableContextPack { .. }
14201        | NodeRequest::ReadContextPack { .. }
14202        | NodeRequest::Shutdown => false,
14203    }
14204}
14205
14206fn request_requires_open_provider_ids_with(
14207    request: &NodeRequest,
14208    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14209    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14210) -> bool {
14211    match request {
14212        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14213        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14214        NodeRequest::CatalogNativeSessions { route, .. }
14215        | NodeRequest::PageNativeSessions { route, .. } => {
14216            !provider_id_is_legacy(&route.provider)
14217        }
14218        NodeRequest::PreviewNativeSession { selection, .. }
14219        | NodeRequest::IndexNativeSession { selection, .. } => {
14220            !provider_id_is_legacy(&selection.route.provider)
14221        }
14222        NodeRequest::SpawnSpec { .. }
14223        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14224        NodeRequest::SpawnManagedWorktree { .. }
14225        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14226        NodeRequest::Resume { session, .. }
14227        | NodeRequest::Prompt { session, .. }
14228        | NodeRequest::Paste { session, .. }
14229        | NodeRequest::Input { session, .. }
14230        | NodeRequest::TerminalBytes { session, .. }
14231        | NodeRequest::TerminalControl { session, .. }
14232        | NodeRequest::Resize { session, .. }
14233        | NodeRequest::Interrupt { session }
14234        | NodeRequest::Stop { session, .. }
14235        | NodeRequest::Remove { session }
14236        | NodeRequest::ResolveInteraction { session, .. }
14237        | NodeRequest::SetSessionMode { session, .. }
14238        | NodeRequest::SetSessionConfigOption { session, .. }
14239        | NodeRequest::SetSessionModel { session, .. }
14240        | NodeRequest::DiscoverHistory { session, .. }
14241        | NodeRequest::LoadHistory { session, .. }
14242        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14243        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14244            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14245        NodeRequest::RenameSessionRecord { record_id, .. }
14246        | NodeRequest::SetSessionTask { record_id, .. }
14247        | NodeRequest::ResumeSessionRecord { record_id, .. }
14248        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14249            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14250        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14251            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14252        NodeRequest::ArmHarnessMcpReservation { .. }
14253        | NodeRequest::ActivateHarnessMcpReservation { .. }
14254        | NodeRequest::AbortHarnessMcpReservation { .. }
14255        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14256        | NodeRequest::RejectHarnessMcpCall { .. }
14257        | NodeRequest::Snapshot
14258        | NodeRequest::Resync { .. }
14259        | NodeRequest::BeginDeliveryStage { .. }
14260        | NodeRequest::PutDeliveryBlobChunk { .. }
14261        | NodeRequest::CommitDeliveryStage { .. }
14262        | NodeRequest::AbortDeliveryStage { .. }
14263        | NodeRequest::BrowseHostDirectories { .. }
14264        | NodeRequest::InspectWorkspace { .. }
14265        | NodeRequest::ReadWorkspaceFile { .. }
14266        | NodeRequest::WriteWorkspaceFile { .. }
14267        | NodeRequest::CreateWorkspaceFile { .. }
14268        | NodeRequest::CreateWorkspaceDirectory { .. }
14269        | NodeRequest::ReadGitHistory { .. }
14270        | NodeRequest::ReadGitDiff { .. }
14271        | NodeRequest::AcquireController { .. }
14272        | NodeRequest::ReleaseController
14273        | NodeRequest::RegisterWorkspace { .. }
14274        | NodeRequest::CreateStandaloneWorkspace { .. }
14275        | NodeRequest::UnregisterWorkspace { .. }
14276        | NodeRequest::CreateWorktree { .. }
14277        | NodeRequest::RemoveWorktree { .. }
14278        | NodeRequest::CleanupManagedWorktree { .. }
14279        | NodeRequest::Shutdown => false,
14280        NodeRequest::ForgetContextPack { .. } => true,
14281        // No session/record to cheaply resolve a provider from here either;
14282        // the resolved receipt can carry an open-id provider, so require the
14283        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14284        // is the same digest-keyed lookup one step further into the same
14285        // catalog, so it inherits the same conservative answer.
14286        NodeRequest::ResolveDurableContextPack { .. }
14287        | NodeRequest::ReadContextPack { .. } => true,
14288    }
14289}
14290
14291fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14292    snapshot.enabled_providers.retain(provider_id_is_legacy);
14293    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14294        snapshot
14295            .provider_runtime_statuses
14296            .as_slice()
14297            .iter()
14298            .filter(|status| provider_id_is_legacy(status.provider()))
14299            .cloned(),
14300    )
14301    .expect("a filtered provider runtime projection remains valid");
14302    snapshot
14303        .session_records
14304        .retain(|record| provider_id_is_legacy(&record.provider));
14305    for workspace in &mut snapshot.workspaces {
14306        workspace
14307            .sessions
14308            .retain(|session| provider_id_is_legacy(&session.agent_id));
14309    }
14310}
14311
14312fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14313    for record in &mut snapshot.session_records {
14314        record.environment_profile = None;
14315    }
14316    if let Some(profiles) = snapshot
14317        .launch_inventory
14318        .as_mut()
14319        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14320    {
14321        for profile in profiles {
14322            profile.environment_profile = None;
14323        }
14324    }
14325}
14326
14327fn project_event_without_child_environment_profile(
14328    mut envelope: NodeEventEnvelope,
14329) -> NodeEventEnvelope {
14330    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14331        record.environment_profile = None;
14332    }
14333    envelope
14334}
14335
14336fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14337    let contains_environment_profile = match reply.result.as_ref() {
14338        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14339            receipt.environment_profile.is_some()
14340        }
14341        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14342            receipt.spawn.environment_profile.is_some()
14343        }
14344        _ => false,
14345    };
14346    if contains_environment_profile {
14347        reply.result = Err(failure(
14348            NodeFailureCode::UnsupportedCapability,
14349            "child environment profiles were not negotiated",
14350        ));
14351        return;
14352    }
14353    let Ok(response) = reply.result.as_mut() else {
14354        return;
14355    };
14356    match response {
14357        NodeResponse::Snapshot { snapshot, .. } => {
14358            clear_snapshot_child_environment_profiles(snapshot);
14359        }
14360        NodeResponse::Resync { snapshot, events, .. } => {
14361            clear_snapshot_child_environment_profiles(snapshot);
14362            for event in events {
14363                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14364                    record.environment_profile = None;
14365                }
14366            }
14367        }
14368        NodeResponse::SessionRecordUpdated { record }
14369        | NodeResponse::ProviderSessionIndexed { record }
14370        | NodeResponse::NativeSessionIndexed { record, .. }
14371        | NodeResponse::SessionRecordResumed { record, .. } => {
14372            record.environment_profile = None;
14373        }
14374        NodeResponse::Armed { .. }
14375        | NodeResponse::Spawned { .. }
14376        | NodeResponse::Activated { .. }
14377        | NodeResponse::Aborted { .. }
14378        | NodeResponse::ReplyChunkAccepted { .. }
14379        | NodeResponse::CallRejected { .. }
14380        | NodeResponse::DeliveryStageBegun { .. }
14381        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14382        | NodeResponse::DeliveryCommitted { .. }
14383        | NodeResponse::DeliveryStageAborted { .. }
14384        | NodeResponse::WorkspaceInspected { .. }
14385        | NodeResponse::HostDirectoriesBrowsed { .. }
14386        | NodeResponse::WorkspaceFileRead { .. }
14387        | NodeResponse::WorkspaceFileWritten { .. }
14388        | NodeResponse::WorkspaceFileCreated { .. }
14389        | NodeResponse::WorkspaceDirectoryCreated { .. }
14390        | NodeResponse::GitHistoryRead { .. }
14391        | NodeResponse::GitDiffRead { .. }
14392        | NodeResponse::Controller { .. }
14393        | NodeResponse::SpawnAccepted { .. }
14394        | NodeResponse::SpawnSpecAccepted { .. }
14395        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14396        | NodeResponse::ManagedWorktreeCleanup { .. }
14397        | NodeResponse::NativeSessionsCataloged { .. }
14398        | NodeResponse::NativeSessionsPaged { .. }
14399        | NodeResponse::NativeSessionPreviewed { .. }
14400        | NodeResponse::SessionRecordPreviewed { .. }
14401        | NodeResponse::HistoryDiscovered { .. }
14402        | NodeResponse::HistoryLoaded { .. }
14403        | NodeResponse::ContextPackForSessionRecordExported { .. }
14404        | NodeResponse::ContextPackExported { .. }
14405        | NodeResponse::ContextPackForgotten { .. }
14406        | NodeResponse::DurableContextPackResolved { .. }
14407        | NodeResponse::ContextPackBytesRead { .. }
14408        | NodeResponse::SessionRecordForgotten { .. }
14409        | NodeResponse::WorkspaceRegistered { .. }
14410        | NodeResponse::StandaloneWorkspaceCreated { .. }
14411        | NodeResponse::WorkspaceUnregistered { .. }
14412        | NodeResponse::WorktreeCreated { .. }
14413        | NodeResponse::WorktreeRemoved { .. }
14414        | NodeResponse::Accepted
14415        | NodeResponse::ShuttingDown => {}
14416    }
14417}
14418
14419fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14420    for record in &mut snapshot.session_records {
14421        record.bundle = None;
14422    }
14423}
14424
14425fn project_event_without_session_bundle(
14426    mut envelope: NodeEventEnvelope,
14427) -> NodeEventEnvelope {
14428    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14429        record.bundle = None;
14430    }
14431    envelope
14432}
14433
14434fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14435    let contains_bundle = match reply.result.as_ref() {
14436        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14437        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14438            receipt.spawn.bundle.is_some()
14439        }
14440        _ => false,
14441    };
14442    if contains_bundle {
14443        reply.result = Err(failure(
14444            NodeFailureCode::UnsupportedCapability,
14445            "session bundles were not negotiated",
14446        ));
14447        return;
14448    }
14449    let Ok(response) = reply.result.as_mut() else {
14450        return;
14451    };
14452    match response {
14453        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14454        NodeResponse::Resync { snapshot, events, .. } => {
14455            clear_snapshot_session_bundles(snapshot);
14456            for event in events {
14457                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14458                    record.bundle = None;
14459                }
14460            }
14461        }
14462        NodeResponse::SessionRecordUpdated { record }
14463        | NodeResponse::ProviderSessionIndexed { record }
14464        | NodeResponse::NativeSessionIndexed { record, .. }
14465        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14466        NodeResponse::Armed { .. }
14467        | NodeResponse::Spawned { .. }
14468        | NodeResponse::Activated { .. }
14469        | NodeResponse::Aborted { .. }
14470        | NodeResponse::ReplyChunkAccepted { .. }
14471        | NodeResponse::CallRejected { .. }
14472        | NodeResponse::DeliveryStageBegun { .. }
14473        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14474        | NodeResponse::DeliveryCommitted { .. }
14475        | NodeResponse::DeliveryStageAborted { .. }
14476        | NodeResponse::WorkspaceInspected { .. }
14477        | NodeResponse::HostDirectoriesBrowsed { .. }
14478        | NodeResponse::WorkspaceFileRead { .. }
14479        | NodeResponse::WorkspaceFileWritten { .. }
14480        | NodeResponse::WorkspaceFileCreated { .. }
14481        | NodeResponse::WorkspaceDirectoryCreated { .. }
14482        | NodeResponse::GitHistoryRead { .. }
14483        | NodeResponse::GitDiffRead { .. }
14484        | NodeResponse::Controller { .. }
14485        | NodeResponse::SpawnAccepted { .. }
14486        | NodeResponse::SpawnSpecAccepted { .. }
14487        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14488        | NodeResponse::ManagedWorktreeCleanup { .. }
14489        | NodeResponse::NativeSessionsCataloged { .. }
14490        | NodeResponse::NativeSessionsPaged { .. }
14491        | NodeResponse::NativeSessionPreviewed { .. }
14492        | NodeResponse::SessionRecordPreviewed { .. }
14493        | NodeResponse::HistoryDiscovered { .. }
14494        | NodeResponse::HistoryLoaded { .. }
14495        | NodeResponse::ContextPackForSessionRecordExported { .. }
14496        | NodeResponse::ContextPackExported { .. }
14497        | NodeResponse::ContextPackForgotten { .. }
14498        | NodeResponse::DurableContextPackResolved { .. }
14499        | NodeResponse::ContextPackBytesRead { .. }
14500        | NodeResponse::SessionRecordForgotten { .. }
14501        | NodeResponse::WorkspaceRegistered { .. }
14502        | NodeResponse::StandaloneWorkspaceCreated { .. }
14503        | NodeResponse::WorkspaceUnregistered { .. }
14504        | NodeResponse::WorktreeCreated { .. }
14505        | NodeResponse::WorktreeRemoved { .. }
14506        | NodeResponse::Accepted
14507        | NodeResponse::ShuttingDown => {}
14508    }
14509}
14510
14511fn project_snapshot_history_for_wire(
14512    snapshot: &mut NodeSnapshot,
14513    include_history_metadata: bool,
14514) {
14515    for workspace in &mut snapshot.workspaces {
14516        for session in &mut workspace.sessions {
14517            if include_history_metadata {
14518                session.history.loaded = None;
14519            } else {
14520                session.history = Default::default();
14521            }
14522        }
14523    }
14524}
14525
14526fn project_event_history_for_wire(
14527    mut envelope: NodeEventEnvelope,
14528    include_history_metadata: bool,
14529) -> NodeEventEnvelope {
14530    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14531        for session in &mut workspace.sessions {
14532            if include_history_metadata {
14533                session.history.loaded = None;
14534            } else {
14535                session.history = Default::default();
14536            }
14537        }
14538    }
14539    envelope
14540}
14541
14542fn project_response_history_for_wire(
14543    reply: &mut ResponseEnvelope,
14544    include_history_metadata: bool,
14545) {
14546    let Ok(response) = reply.result.as_mut() else {
14547        return;
14548    };
14549    match response {
14550        NodeResponse::Snapshot { snapshot, .. } => {
14551            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14552        }
14553        NodeResponse::Resync {
14554            snapshot, events, ..
14555        } => {
14556            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14557            for event in events {
14558                *event = project_event_history_for_wire(
14559                    event.clone(),
14560                    include_history_metadata,
14561                );
14562            }
14563        }
14564        NodeResponse::WorkspaceRegistered { workspace }
14565        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14566        | NodeResponse::WorktreeCreated { workspace, .. } => {
14567            for session in &mut workspace.sessions {
14568                if include_history_metadata {
14569                    session.history.loaded = None;
14570                } else {
14571                    session.history = Default::default();
14572                }
14573            }
14574        }
14575        _ => {}
14576    }
14577}
14578
14579fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14580    for record in &mut snapshot.session_records {
14581        record.context_id = None;
14582        record.context = None;
14583        record.exported_context = None;
14584    }
14585}
14586
14587fn project_event_without_context_pack(
14588    mut envelope: NodeEventEnvelope,
14589) -> NodeEventEnvelope {
14590    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14591        record.context_id = None;
14592        record.context = None;
14593        record.exported_context = None;
14594    }
14595    envelope
14596}
14597
14598fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14599    let contains_context = match reply.result.as_ref() {
14600        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14601        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14602            receipt.spawn.context.is_some()
14603        }
14604        Ok(NodeResponse::HistoryDiscovered { .. })
14605        | Ok(NodeResponse::HistoryLoaded { .. })
14606        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14607        | Ok(NodeResponse::ContextPackExported { .. })
14608        | Ok(NodeResponse::ContextPackForgotten { .. })
14609        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14610        _ => false,
14611    };
14612    if contains_context {
14613        reply.result = Err(failure(
14614            NodeFailureCode::UnsupportedCapability,
14615            "history context packs were not negotiated",
14616        ));
14617        return;
14618    }
14619    let Ok(response) = reply.result.as_mut() else {
14620        return;
14621    };
14622    match response {
14623        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14624        NodeResponse::Resync {
14625            snapshot, events, ..
14626        } => {
14627            clear_snapshot_context_packs(snapshot);
14628            for event in events {
14629                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14630                    record.context_id = None;
14631                    record.context = None;
14632                    record.exported_context = None;
14633                }
14634            }
14635        }
14636        NodeResponse::SessionRecordUpdated { record }
14637        | NodeResponse::ProviderSessionIndexed { record }
14638        | NodeResponse::NativeSessionIndexed { record, .. }
14639        | NodeResponse::SessionRecordResumed { record, .. } => {
14640            record.context_id = None;
14641            record.context = None;
14642            record.exported_context = None;
14643        }
14644        _ => {}
14645    }
14646}
14647
14648fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14649    let contains_open_record = match reply.result.as_ref() {
14650        Ok(NodeResponse::SessionRecordUpdated { record })
14651        | Ok(NodeResponse::ProviderSessionIndexed { record })
14652        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14653            !provider_id_is_legacy(&record.provider)
14654        }
14655        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14656            !provider_id_is_legacy(&selection.route.provider)
14657                || !provider_id_is_legacy(&record.provider)
14658        }
14659        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14660            !provider_id_is_legacy(&receipt.provider)
14661        }
14662        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14663            !provider_id_is_legacy(&receipt.spawn.provider)
14664        }
14665        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14666        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14667            !provider_id_is_legacy(&route.provider)
14668        }
14669        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14670            !provider_id_is_legacy(&selection.route.provider)
14671        }
14672        _ => false,
14673    };
14674    if contains_open_record {
14675        reply.result = Err(failure(
14676            NodeFailureCode::UnsupportedCapability,
14677            "open provider IDs were not negotiated",
14678        ));
14679        return;
14680    }
14681    let Ok(response) = reply.result.as_mut() else {
14682        return;
14683    };
14684    match response {
14685        NodeResponse::Snapshot { snapshot, .. } => {
14686            project_snapshot_legacy_provider_ids(snapshot);
14687        }
14688        NodeResponse::Resync { snapshot, events, .. } => {
14689            project_snapshot_legacy_provider_ids(snapshot);
14690            *events = std::mem::take(events)
14691                .into_iter()
14692                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14693                .collect();
14694        }
14695        NodeResponse::WorkspaceRegistered { workspace }
14696        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14697        | NodeResponse::WorktreeCreated { workspace, .. } => {
14698            workspace
14699                .sessions
14700                .retain(|session| provider_id_is_legacy(&session.agent_id));
14701        }
14702        NodeResponse::Armed { .. }
14703        | NodeResponse::Spawned { .. }
14704        | NodeResponse::Activated { .. }
14705        | NodeResponse::Aborted { .. }
14706        | NodeResponse::ReplyChunkAccepted { .. }
14707        | NodeResponse::CallRejected { .. }
14708        | NodeResponse::DeliveryStageBegun { .. }
14709        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14710        | NodeResponse::DeliveryCommitted { .. }
14711        | NodeResponse::DeliveryStageAborted { .. }
14712        | NodeResponse::WorkspaceInspected { .. }
14713        | NodeResponse::HostDirectoriesBrowsed { .. }
14714        | NodeResponse::WorkspaceFileRead { .. }
14715        | NodeResponse::WorkspaceFileWritten { .. }
14716        | NodeResponse::WorkspaceFileCreated { .. }
14717        | NodeResponse::WorkspaceDirectoryCreated { .. }
14718        | NodeResponse::GitHistoryRead { .. }
14719        | NodeResponse::GitDiffRead { .. }
14720        | NodeResponse::Controller { .. }
14721        | NodeResponse::SpawnAccepted { .. }
14722        | NodeResponse::SpawnSpecAccepted { .. }
14723        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14724        | NodeResponse::ManagedWorktreeCleanup { .. }
14725        | NodeResponse::NativeSessionsCataloged { .. }
14726        | NodeResponse::NativeSessionsPaged { .. }
14727        | NodeResponse::NativeSessionPreviewed { .. }
14728        | NodeResponse::SessionRecordPreviewed { .. }
14729        | NodeResponse::HistoryDiscovered { .. }
14730        | NodeResponse::HistoryLoaded { .. }
14731        | NodeResponse::ContextPackForSessionRecordExported { .. }
14732        | NodeResponse::ContextPackExported { .. }
14733        | NodeResponse::ContextPackForgotten { .. }
14734        | NodeResponse::DurableContextPackResolved { .. }
14735        | NodeResponse::ContextPackBytesRead { .. }
14736        | NodeResponse::SessionRecordUpdated { .. }
14737        | NodeResponse::ProviderSessionIndexed { .. }
14738        | NodeResponse::NativeSessionIndexed { .. }
14739        | NodeResponse::SessionRecordResumed { .. }
14740        | NodeResponse::SessionRecordForgotten { .. }
14741        | NodeResponse::WorkspaceUnregistered { .. }
14742        | NodeResponse::WorktreeRemoved { .. }
14743        | NodeResponse::Accepted
14744        | NodeResponse::ShuttingDown => {}
14745    }
14746}
14747
14748fn project_event_legacy_provider_ids(
14749    shared: &NodeShared,
14750    mut envelope: NodeEventEnvelope,
14751) -> Option<NodeEventEnvelope> {
14752    let include = match &mut envelope.event {
14753        NodeEvent::HarnessMcpReadCall { .. } => true,
14754        NodeEvent::Control { address, .. }
14755        | NodeEvent::AgentStream { address, .. }
14756        | NodeEvent::TerminalFrame { address, .. } => shared
14757            .validate_address(address)
14758            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14759        NodeEvent::WorkspaceAdded { workspace } => {
14760            workspace
14761                .sessions
14762                .retain(|session| provider_id_is_legacy(&session.agent_id));
14763            true
14764        }
14765        NodeEvent::SessionRecordUpserted { record } => {
14766            provider_id_is_legacy(&record.provider)
14767        }
14768        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14769        NodeEvent::SessionRecordRemoved { .. } => shared
14770            .history
14771            .lock()
14772            .unwrap_or_else(|poisoned| poisoned.into_inner())
14773            .removed_record_providers
14774            .get(&envelope.sequence)
14775            .is_some_and(provider_id_is_legacy),
14776        NodeEvent::ControllerChanged { .. }
14777        | NodeEvent::ManagedWorktreeUpserted { .. }
14778        | NodeEvent::ManagedWorktreeRemoved { .. }
14779        | NodeEvent::WorkspaceRemoved { .. }
14780        | NodeEvent::ResyncRequired { .. } => true,
14781    };
14782    include.then_some(envelope)
14783}
14784
14785fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14786    let Ok(response) = reply.result.as_mut() else {
14787        return;
14788    };
14789    match response {
14790        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14791            snapshot.provider_runtime_statuses.clear();
14792        }
14793        NodeResponse::Armed { .. }
14794        | NodeResponse::Spawned { .. }
14795        | NodeResponse::Activated { .. }
14796        | NodeResponse::Aborted { .. }
14797        | NodeResponse::ReplyChunkAccepted { .. }
14798        | NodeResponse::CallRejected { .. }
14799        | NodeResponse::DeliveryStageBegun { .. }
14800        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14801        | NodeResponse::DeliveryCommitted { .. }
14802        | NodeResponse::DeliveryStageAborted { .. }
14803        | NodeResponse::WorkspaceInspected { .. }
14804        | NodeResponse::HostDirectoriesBrowsed { .. }
14805        | NodeResponse::WorkspaceFileRead { .. }
14806        | NodeResponse::WorkspaceFileWritten { .. }
14807        | NodeResponse::WorkspaceFileCreated { .. }
14808        | NodeResponse::WorkspaceDirectoryCreated { .. }
14809        | NodeResponse::GitHistoryRead { .. }
14810        | NodeResponse::GitDiffRead { .. }
14811        | NodeResponse::Controller { .. }
14812        | NodeResponse::SpawnAccepted { .. }
14813        | NodeResponse::SpawnSpecAccepted { .. }
14814        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14815        | NodeResponse::ManagedWorktreeCleanup { .. }
14816        | NodeResponse::NativeSessionsCataloged { .. }
14817        | NodeResponse::NativeSessionsPaged { .. }
14818        | NodeResponse::NativeSessionPreviewed { .. }
14819        | NodeResponse::SessionRecordPreviewed { .. }
14820        | NodeResponse::HistoryDiscovered { .. }
14821        | NodeResponse::HistoryLoaded { .. }
14822        | NodeResponse::ContextPackForSessionRecordExported { .. }
14823        | NodeResponse::ContextPackExported { .. }
14824        | NodeResponse::ContextPackForgotten { .. }
14825        | NodeResponse::DurableContextPackResolved { .. }
14826        | NodeResponse::ContextPackBytesRead { .. }
14827        | NodeResponse::SessionRecordUpdated { .. }
14828        | NodeResponse::ProviderSessionIndexed { .. }
14829        | NodeResponse::NativeSessionIndexed { .. }
14830        | NodeResponse::SessionRecordResumed { .. }
14831        | NodeResponse::SessionRecordForgotten { .. }
14832        | NodeResponse::WorkspaceRegistered { .. }
14833        | NodeResponse::StandaloneWorkspaceCreated { .. }
14834        | NodeResponse::WorkspaceUnregistered { .. }
14835        | NodeResponse::WorktreeCreated { .. }
14836        | NodeResponse::WorktreeRemoved { .. }
14837        | NodeResponse::Accepted
14838        | NodeResponse::ShuttingDown => {}
14839    }
14840}
14841
14842struct ControllerReleaseGuard {
14843    shared: Arc<NodeShared>,
14844    connection_id: u64,
14845}
14846
14847impl Drop for ControllerReleaseGuard {
14848    fn drop(&mut self) {
14849        self.shared.release_controller(self.connection_id);
14850    }
14851}
14852
14853struct AbortTaskOnDrop(AbortHandle);
14854
14855impl Drop for AbortTaskOnDrop {
14856    fn drop(&mut self) {
14857        self.0.abort();
14858    }
14859}
14860
14861/// Reads an unsigned env-config override, following the pattern of every
14862/// other bounded knob in this module: missing/unparsable/zero falls back to
14863/// `default`, and the resolved value is always clamped to `max` so a bad
14864/// override cannot reopen a bound this budget exists to enforce.
14865fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14866    std::env::var(name)
14867        .ok()
14868        .and_then(|value| value.parse::<u64>().ok())
14869        .filter(|value| *value > 0)
14870        .unwrap_or(default)
14871        .min(max)
14872}
14873
14874fn workspace_inspection_time_budget_ms() -> u64 {
14875    env_config_bounded(
14876        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14877        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14878        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14879    )
14880}
14881
14882fn workspace_inspection_entry_cap() -> usize {
14883    env_config_bounded(
14884        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14885        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14886        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14887    ) as usize
14888}
14889
14890/// Tracks the shared walk-side budget across the whole recursive walk: a
14891/// wall-clock deadline (shared with the git phase that runs after the walk)
14892/// and a cap on directory entries visited, whether or not they were pushed
14893/// to the response. Cheap to check per entry — one `Instant::now()` and one
14894/// integer compare.
14895struct WorkspaceWalkBudget {
14896    deadline: Instant,
14897    entry_cap: usize,
14898    entries_visited: u64,
14899    time_budget_exceeded: bool,
14900    entry_cap_exceeded: bool,
14901}
14902
14903impl WorkspaceWalkBudget {
14904    fn new(deadline: Instant, entry_cap: usize) -> Self {
14905        Self {
14906            deadline,
14907            entry_cap,
14908            entries_visited: 0,
14909            time_budget_exceeded: false,
14910            entry_cap_exceeded: false,
14911        }
14912    }
14913
14914    fn is_exhausted(&self) -> bool {
14915        self.time_budget_exceeded || self.entry_cap_exceeded
14916    }
14917
14918    /// Records one visited directory entry (pushed to the response or not)
14919    /// and reports whether the walk must stop now.
14920    fn record_visit(&mut self) -> bool {
14921        self.entries_visited += 1;
14922        if self.entries_visited as usize >= self.entry_cap {
14923            self.entry_cap_exceeded = true;
14924        }
14925        if Instant::now() >= self.deadline {
14926            self.time_budget_exceeded = true;
14927        }
14928        self.is_exhausted()
14929    }
14930}
14931
14932fn collect_workspace_entries(
14933    root: &Path,
14934    deadline: Instant,
14935    entry_cap: usize,
14936) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14937    let mut entries = Vec::new();
14938    let mut truncated = false;
14939    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14940    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14941    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
14942    // `x-y` ('-' sorts below '/'), while the bounded operator projections
14943    // require strict global byte order over relative paths.
14944    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14945    (entries, truncated, budget)
14946}
14947
14948/// Builds the additive `WorkspaceInspectionTruncationV1` for an
14949/// `inspect_workspace` response — `None` once the walk and git phase both
14950/// completed inside their shared budget.
14951fn workspace_inspection_truncation(
14952    walk_budget: &WorkspaceWalkBudget,
14953    git_time_budget_exceeded: bool,
14954    elapsed: Duration,
14955) -> Option<WorkspaceInspectionTruncationV1> {
14956    if !walk_budget.time_budget_exceeded
14957        && !walk_budget.entry_cap_exceeded
14958        && !git_time_budget_exceeded
14959    {
14960        return None;
14961    }
14962    Some(WorkspaceInspectionTruncationV1 {
14963        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14964        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14965        git_time_budget_exceeded,
14966        entries_visited: walk_budget.entries_visited,
14967        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14968    })
14969}
14970
14971fn walk_workspace_directory(
14972    directory: &Path,
14973    relative_directory: Option<&RepositoryPath>,
14974    depth: usize,
14975    entries: &mut Vec<WorkspaceEntry>,
14976    truncated: &mut bool,
14977    budget: &mut WorkspaceWalkBudget,
14978) {
14979    if budget.is_exhausted() {
14980        *truncated = true;
14981        return;
14982    }
14983    let read_dir = match std::fs::read_dir(directory) {
14984        Ok(read_dir) => read_dir,
14985        Err(_) => {
14986            *truncated = true;
14987            return;
14988        }
14989    };
14990    let mut children = Vec::new();
14991    for child in read_dir {
14992        match child {
14993            Ok(child) => children.push(child),
14994            Err(_) => *truncated = true,
14995        }
14996    }
14997    children.sort_by_key(|child| child.file_name());
14998    for child in children {
14999        if budget.record_visit() {
15000            *truncated = true;
15001            return;
15002        }
15003        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15004            *truncated = true;
15005            return;
15006        }
15007        let file_type = match child.file_type() {
15008            Ok(file_type) => file_type,
15009            Err(_) => {
15010                *truncated = true;
15011                continue;
15012            }
15013        };
15014        if file_type.is_symlink() {
15015            continue;
15016        }
15017        let name = match child.file_name().into_string() {
15018            Ok(name) => name,
15019            Err(_) => {
15020                *truncated = true;
15021                continue;
15022            }
15023        };
15024        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15025            continue;
15026        }
15027        let relative_path = match relative_directory {
15028            None => windows_repository_path(name),
15029            Some(parent) => parent
15030                .as_utf8()
15031                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15032        };
15033        let Some(relative_path) = relative_path else {
15034            *truncated = true;
15035            continue;
15036        };
15037        let kind = if file_type.is_dir() {
15038            WorkspaceEntryKind::Directory
15039        } else if file_type.is_file() {
15040            WorkspaceEntryKind::File
15041        } else {
15042            continue;
15043        };
15044        entries.push(WorkspaceEntry {
15045            relative_path: relative_path.clone(),
15046            kind,
15047        });
15048        if file_type.is_dir() {
15049            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15050                walk_workspace_directory(
15051                    &child.path(),
15052                    Some(&relative_path),
15053                    depth + 1,
15054                    entries,
15055                    truncated,
15056                    budget,
15057                );
15058            } else {
15059                *truncated = true;
15060            }
15061        }
15062    }
15063}
15064
15065fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15066    if value.contains('\\') {
15067        return None;
15068    }
15069    RepositoryPath::utf8(value).ok()
15070}
15071
15072fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15073    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15074}
15075
15076/// Excludes a directory from the workspace tree walk. The three literal
15077/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15078/// beneath them catches every build-output directory regardless of name
15079/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15080/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15081/// directory without maintaining a name heuristic list). The name check
15082/// runs first and returns early so a directory rejected by name never pays
15083/// for the extra `CACHEDIR.TAG` stat.
15084fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15085    if name.eq_ignore_ascii_case(".git")
15086        || name.eq_ignore_ascii_case("target")
15087        || name.eq_ignore_ascii_case("node_modules")
15088    {
15089        return true;
15090    }
15091    path.join("CACHEDIR.TAG").is_file()
15092}
15093
15094fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15095    matches!(
15096        status,
15097        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15098    )
15099}
15100
15101#[derive(Clone, Debug, Eq, PartialEq)]
15102enum ContextPackRepositoryHead {
15103    NotRepository,
15104    Unborn,
15105    Commit(String),
15106}
15107
15108#[derive(Clone, Debug, Eq, PartialEq)]
15109struct ContextPackRepositoryObservation {
15110    head: ContextPackRepositoryHead,
15111    git: GitSnapshot,
15112    files: Vec<ContextPackRepositoryFileSource>,
15113}
15114
15115fn stable_context_pack_repository(
15116    first: ContextPackRepositoryObservation,
15117    second: ContextPackRepositoryObservation,
15118) -> Result<ContextPackRepository, NodeFailure> {
15119    if first != second {
15120        return Err(failure(
15121            NodeFailureCode::ContextPackMaterializationFailed,
15122            "context repository changed during bounded capture",
15123        ));
15124    }
15125    Ok(ContextPackRepository::from_sources(
15126        &second.git,
15127        second.files,
15128    ))
15129}
15130
15131/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15132/// (and only once) the shared walk+git `deadline` has already passed. Every
15133/// remaining phase in `inspect_git_workspace` checks this immediately
15134/// before issuing its own git call, so a budget spent by the walk (or by an
15135/// earlier git call) skips the rest of the chain instead of paying for it
15136/// anyway.
15137fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15138    if Instant::now() < deadline {
15139        return false;
15140    }
15141    append_git_diagnostic(
15142        snapshot,
15143        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15144    );
15145    true
15146}
15147
15148/// Deadline-aware sibling of `run_git_bounded`, used only by
15149/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15150/// whatever remains of the shared `deadline` (never more than
15151/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15152/// of the budget cannot still hand a full per-call timeout to every git
15153/// probe behind it. `inspect_git_workspace` already guards each call site
15154/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15155/// passed yet here.
15156async fn run_git_bounded_with_deadline(
15157    root: &str,
15158    arguments: &[&str],
15159    output_limit: usize,
15160    deadline: Instant,
15161) -> io::Result<GitCommandOutput> {
15162    let remaining = deadline.checked_duration_since(Instant::now());
15163    let timeout_ms = remaining
15164        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15165        .unwrap_or(0)
15166        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15167    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15168}
15169
15170/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15171/// `deadline` is the same absolute instant the preceding directory walk was
15172/// bounded by, so time already spent walking counts against what remains
15173/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15174/// shared budget (not an individual per-call timeout) is what cut the git
15175/// phase short.
15176async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15177    let mut snapshot = GitSnapshot {
15178        is_repository: false,
15179        branch: None,
15180        status: Vec::new(),
15181        recent_commits: Vec::new(),
15182        worktrees: Vec::new(),
15183        managed_worktree: None,
15184        truncated: false,
15185        diagnostic: None,
15186    };
15187    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15188        return (snapshot, true);
15189    }
15190    let repository = match run_git_bounded_with_deadline(
15191        root,
15192        &["rev-parse", "--is-inside-work-tree"],
15193        4 * 1_024,
15194        deadline,
15195    )
15196    .await
15197    {
15198        Ok(output) => output,
15199        Err(error) => {
15200            append_git_diagnostic(
15201                &mut snapshot,
15202                &format!("git inspection unavailable: {error}"),
15203            );
15204            return (snapshot, false);
15205        }
15206    };
15207    snapshot.truncated |= repository.truncated;
15208    if repository.timed_out {
15209        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15210        return (snapshot, false);
15211    }
15212    if !repository.success
15213        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15214    {
15215        let stderr = String::from_utf8_lossy(&repository.stderr);
15216        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15217            append_git_diagnostic(&mut snapshot, stderr.trim());
15218        }
15219        return (snapshot, false);
15220    }
15221    snapshot.is_repository = true;
15222
15223    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15224        return (snapshot, true);
15225    }
15226    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15227        Ok(output) => {
15228            snapshot.truncated |= output.truncated;
15229            if output.timed_out {
15230                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15231            } else if output.success {
15232                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15233                if !branch.is_empty() {
15234                    snapshot.branch = Some(truncate_git_field(
15235                        &branch,
15236                        MAX_REPOSITORY_PATH_BYTES,
15237                        &mut snapshot.truncated,
15238                    ));
15239                }
15240            }
15241        }
15242        Err(error) => append_git_diagnostic(
15243            &mut snapshot,
15244            &format!("git branch query failed: {error}"),
15245        ),
15246    }
15247    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15248        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15249            snapshot.truncated |= output.truncated;
15250            if output.success && !output.timed_out {
15251                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15252                if !head.is_empty() {
15253                    snapshot.branch = Some(format!("detached:{head}"));
15254                }
15255            }
15256        }
15257    }
15258
15259    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15260        return (snapshot, true);
15261    }
15262    match run_git_bounded_with_deadline(
15263        root,
15264        &[
15265            "status",
15266            "--porcelain=v1",
15267            "-z",
15268            "--renames",
15269            "--untracked-files=normal",
15270            "--",
15271            ".",
15272        ],
15273        GIT_OUTPUT_MAX_BYTES,
15274        deadline,
15275    )
15276    .await
15277    {
15278        Ok(output) => {
15279            snapshot.truncated |= output.truncated;
15280            if output.timed_out {
15281                append_git_diagnostic(&mut snapshot, "git status timed out");
15282            } else if output.success {
15283                parse_git_status(&output.stdout, &mut snapshot);
15284            } else {
15285                append_git_diagnostic(
15286                    &mut snapshot,
15287                    String::from_utf8_lossy(&output.stderr).trim(),
15288                );
15289            }
15290        }
15291        Err(error) => append_git_diagnostic(
15292            &mut snapshot,
15293            &format!("git status failed: {error}"),
15294        ),
15295    }
15296
15297    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15298        return (snapshot, true);
15299    }
15300    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15301    match run_git_bounded_with_deadline(
15302        root,
15303        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15304        16 * 1_024,
15305        deadline,
15306    )
15307    .await
15308    {
15309        Ok(output) => {
15310            snapshot.truncated |= output.truncated;
15311            if output.timed_out {
15312                append_git_diagnostic(&mut snapshot, "git log timed out");
15313            } else if output.success {
15314                parse_git_commits(&output.stdout, &mut snapshot);
15315            } else {
15316                let stderr = String::from_utf8_lossy(&output.stderr);
15317                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15318                    append_git_diagnostic(&mut snapshot, stderr.trim());
15319                }
15320            }
15321        }
15322        Err(error) => append_git_diagnostic(
15323            &mut snapshot,
15324            &format!("git log failed: {error}"),
15325        ),
15326    }
15327
15328    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15329        return (snapshot, true);
15330    }
15331    match list_git_worktrees_with_deadline(root, deadline).await {
15332        Ok(worktrees) => {
15333            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15334        }
15335        Err(error) => append_git_diagnostic(
15336            &mut snapshot,
15337            &format!("git worktree list failed: {}", error.message),
15338        ),
15339    }
15340    (snapshot, false)
15341}
15342
15343fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15344    let mut remaining = output;
15345    while !remaining.is_empty() {
15346        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15347            snapshot.truncated = true;
15348            break;
15349        }
15350        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15351            snapshot.truncated = true;
15352            break;
15353        };
15354        let record = &remaining[..record_end];
15355        remaining = &remaining[record_end + 1..];
15356        let has_previous_path = record
15357            .get(..2)
15358            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15359        let previous_path_bytes = if has_previous_path {
15360            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15361                snapshot.truncated = true;
15362                break;
15363            };
15364            let previous = &remaining[..previous_end];
15365            remaining = &remaining[previous_end + 1..];
15366            Some(previous)
15367        } else {
15368            None
15369        };
15370        if record.len() < 4
15371            || record[2] != b' '
15372            || !is_git_status_code(record[0])
15373            || !is_git_status_code(record[1])
15374        {
15375            snapshot.truncated = true;
15376            continue;
15377        }
15378        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15379            snapshot.truncated = true;
15380            continue;
15381        };
15382        let previous_path = match previous_path_bytes {
15383            Some(previous) => {
15384                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15385                    snapshot.truncated = true;
15386                    continue;
15387                };
15388                Some(previous)
15389            }
15390            None => None,
15391        };
15392        snapshot.status.push(GitStatusEntry {
15393            index_status: (record[0] as char).to_string(),
15394            worktree_status: (record[1] as char).to_string(),
15395            path,
15396            previous_path,
15397        });
15398    }
15399}
15400
15401fn is_git_status_code(byte: u8) -> bool {
15402    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15403}
15404
15405fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15406    for line in String::from_utf8_lossy(output).lines() {
15407        let Some((id, summary)) = line.split_once('\u{1f}') else {
15408            continue;
15409        };
15410        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15411            snapshot.truncated = true;
15412            break;
15413        }
15414        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15415        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15416        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15417    }
15418}
15419
15420fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15421    if value.len() <= max_bytes {
15422        return value.to_owned();
15423    }
15424    *truncated = true;
15425    let mut boundary = max_bytes;
15426    while boundary > 0 && !value.is_char_boundary(boundary) {
15427        boundary -= 1;
15428    }
15429    value[..boundary].to_owned()
15430}
15431
15432fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15433    if message.is_empty() {
15434        return;
15435    }
15436    let normalized = message
15437        .chars()
15438        .map(|character| if character.is_control() { ' ' } else { character })
15439        .collect::<String>();
15440    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15441    if !diagnostic.is_empty() {
15442        diagnostic.push_str("; ");
15443    }
15444    diagnostic.push_str(normalized.trim());
15445    snapshot.diagnostic = Some(truncate_git_field(
15446        &diagnostic,
15447        GIT_DIAGNOSTIC_MAX_BYTES,
15448        &mut snapshot.truncated,
15449    ));
15450}
15451
15452async fn run_git_bounded(
15453    root: &str,
15454    arguments: &[&str],
15455    output_limit: usize,
15456) -> io::Result<GitCommandOutput> {
15457    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15458}
15459
15460async fn read_git_history_bounded(
15461    root: &str,
15462    path: Option<&RepositoryPath>,
15463    before: Option<&GitObjectId>,
15464    limit: u16,
15465) -> Result<GitHistoryPage, NodeFailure> {
15466    let path = path
15467        .map(|path| {
15468            path.as_utf8().ok_or_else(|| {
15469                failure(
15470                    NodeFailureCode::InvalidRepositoryPath,
15471                    "git history path must use UTF-8",
15472                )
15473            })
15474        })
15475        .transpose()?
15476        .unwrap_or(".");
15477    let count = (usize::from(limit) + 1).to_string();
15478    let start = before
15479        .map(|revision| format!("{}^", revision.as_str()))
15480        .unwrap_or_else(|| "HEAD".to_owned());
15481    let arguments = [
15482        "--no-pager",
15483        "log",
15484        "--no-decorate",
15485        "--date=iso-strict",
15486        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15487        "-n",
15488        count.as_str(),
15489        start.as_str(),
15490        "--",
15491        path,
15492    ];
15493    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15494        .await
15495        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15496    if output.timed_out {
15497        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15498    }
15499    if !output.success {
15500        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15501    }
15502    let mut commits = parse_git_history(&output.stdout);
15503    let has_more = commits.len() > usize::from(limit) || output.truncated;
15504    commits.truncate(usize::from(limit));
15505    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15506    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15507}
15508
15509fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15510    String::from_utf8_lossy(output)
15511        .split('\u{1e}')
15512        .filter_map(|record| {
15513            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15514            if fields.len() != 9 {
15515                return None;
15516            }
15517            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15518            let parents = if fields[1].is_empty() {
15519                Vec::new()
15520            } else {
15521                fields[1]
15522                    .split(' ')
15523                    .map(|value| GitObjectId::new(value.to_owned()))
15524                    .collect::<Result<Vec<_>, _>>()
15525                    .ok()?
15526            };
15527            Some(GitCommitDetails {
15528                id,
15529                parents,
15530                subject: bounded_git_text(fields[2], 512),
15531                author_name: bounded_git_text(fields[3], 256),
15532                author_email: bounded_git_text(fields[4], 320),
15533                authored_at: bounded_git_text(fields[5], 64),
15534                committer_name: bounded_git_text(fields[6], 256),
15535                committer_email: bounded_git_text(fields[7], 320),
15536                committed_at: bounded_git_text(fields[8], 64),
15537                signature_status: GitSignatureStatus::NoSignature,
15538                signer: None,
15539            })
15540        })
15541        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15542        .collect()
15543}
15544
15545fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15546    let normalized = value
15547        .chars()
15548        .map(|character| if character.is_control() { ' ' } else { character })
15549        .collect::<String>();
15550    if normalized.len() <= max_bytes {
15551        return normalized;
15552    }
15553    let mut boundary = max_bytes;
15554    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15555        boundary -= 1;
15556    }
15557    normalized[..boundary].to_owned()
15558}
15559
15560async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15561    let path = request.path.as_ref().map(|path| {
15562        path.as_utf8()
15563            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15564    }).transpose()?;
15565    let mut arguments = match &request.mode {
15566        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15567        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15568        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15569    };
15570    if let GitDiffMode::Commit { revision } = &request.mode {
15571        arguments.push(revision.as_str());
15572    }
15573    arguments.push("--");
15574    if let Some(path) = path {
15575        arguments.push(path);
15576    }
15577    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15578        .await
15579        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15580    if output.timed_out {
15581        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15582    }
15583    if !output.success {
15584        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15585    }
15586    Ok(GitDiff {
15587        mode: request.mode,
15588        path: request.path,
15589        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15590        truncated: output.truncated,
15591    })
15592}
15593
15594async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15595    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15596    ResponseEnvelope { request_id: envelope.request_id, result }
15597}
15598
15599async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15600    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15601        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15602    }
15603    let read_only = matches!(
15604        &request,
15605        NodeRequest::Snapshot
15606            | NodeRequest::Resync { .. }
15607            | NodeRequest::BrowseHostDirectories { .. }
15608            | NodeRequest::InspectWorkspace { .. }
15609            | NodeRequest::ReadWorkspaceFile { .. }
15610            | NodeRequest::ReadGitHistory { .. }
15611            | NodeRequest::ReadGitDiff { .. }
15612            | NodeRequest::CatalogNativeSessions { .. }
15613            | NodeRequest::PageNativeSessions { .. }
15614            | NodeRequest::PreviewNativeSession { .. }
15615            | NodeRequest::PreviewSessionRecord { .. }
15616    );
15617    let _mutation_guard = if read_only {
15618        None
15619    } else {
15620        let guard = shared.mutation_gate.lock().await;
15621        if shared.shutdown.load(Ordering::Acquire) {
15622            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15623        }
15624        Some(guard)
15625    };
15626    match request {
15627        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15628            event_sequence: shared.current_sequence(),
15629            controller: shared.controller_state(),
15630            snapshot: shared.snapshot(),
15631        }),
15632        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15633        NodeRequest::ArmHarnessMcpReservation {
15634            reservation_id,
15635            activation_digest,
15636            spawn_spec,
15637            launch,
15638            expires_at_unix_ms,
15639        } => {
15640            shared.require_controller(connection_id, role)?;
15641            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15642                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15643            })?;
15644            let expires_at_unix_ms = registry
15645                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15646                .await
15647                .map_err(harness_mcp_failure)?;
15648            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15649        }
15650        NodeRequest::SpawnSpecWithHarnessMcp {
15651            reservation_id,
15652            activation_digest,
15653            spec,
15654            deadline_unix_ms,
15655        } => {
15656            shared.require_controller(connection_id, role)?;
15657            let receipt = shared
15658                .spawn_from_spec_with_harness_mcp(
15659                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15660                )
15661                .await?;
15662            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15663        }
15664        NodeRequest::ActivateHarnessMcpReservation {
15665            reservation_id,
15666            activation_digest,
15667            record_id,
15668            session,
15669        } => {
15670            shared.require_controller(connection_id, role)?;
15671            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15672                current.instance_id == session.session.instance_id
15673                    && current.generation == session.session.generation
15674            }).and_then(|current| current.process_id).ok_or_else(|| {
15675                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15676            })?;
15677            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15678                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15679            })?.activate(
15680                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15681            ).map_err(harness_mcp_failure)?;
15682            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15683        }
15684        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15685            shared.require_controller(connection_id, role)?;
15686            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15687                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15688            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15689            if let Some(instance_id) = instance_id {
15690                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15691            }
15692            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15693        }
15694        NodeRequest::PutHarnessMcpReplyChunk {
15695            reservation_id, activation_digest, record_id, session, call_id,
15696            offset, final_chunk, chunk_hex,
15697        } => {
15698            shared.require_controller(connection_id, role)?;
15699            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15700                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15701            })?.put_reply_chunk(
15702                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15703                offset, final_chunk, &chunk_hex,
15704            ).map_err(harness_mcp_failure)?;
15705            Ok(NodeResponse::ReplyChunkAccepted {
15706                reservation_id, activation_digest, record_id, session, call_id,
15707                next_offset, completed,
15708            })
15709        }
15710        NodeRequest::RejectHarnessMcpCall {
15711            reservation_id, activation_digest, record_id, session, call_id, reason,
15712        } => {
15713            shared.require_controller(connection_id, role)?;
15714            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15715                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15716            })?.reject_call(
15717                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15718            ).map_err(harness_mcp_failure)?;
15719            Ok(NodeResponse::CallRejected {
15720                reservation_id, activation_digest, record_id, session, call_id,
15721            })
15722        }
15723        NodeRequest::BeginDeliveryStage { manifest } => {
15724            shared.require_controller(connection_id, role)?;
15725            shared.begin_delivery_stage(manifest)
15726        }
15727        NodeRequest::PutDeliveryBlobChunk {
15728            stage_id,
15729            blob_digest,
15730            offset,
15731            chunk_hex,
15732        } => {
15733            shared.require_controller(connection_id, role)?;
15734            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15735        }
15736        NodeRequest::CommitDeliveryStage { stage_id } => {
15737            shared.require_controller(connection_id, role)?;
15738            let receipt = shared.commit_delivery_stage(stage_id)?;
15739            Ok(NodeResponse::DeliveryCommitted { receipt })
15740        }
15741        NodeRequest::AbortDeliveryStage { stage_id } => {
15742            shared.require_controller(connection_id, role)?;
15743            shared.abort_delivery_stage(&stage_id)?;
15744            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15745        }
15746        NodeRequest::BrowseHostDirectories { directory, after } => {
15747            let listing = shared.browse_host_directories(directory, after).await?;
15748            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15749        }
15750        NodeRequest::InspectWorkspace { workspace_id } => {
15751            let inspection = shared.inspect_workspace(workspace_id).await?;
15752            Ok(NodeResponse::WorkspaceInspected { inspection })
15753        }
15754        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15755            let file = shared.read_workspace_file(workspace_id, path).await?;
15756            Ok(NodeResponse::WorkspaceFileRead { file })
15757        }
15758        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15759            shared.require_controller(connection_id, role)?;
15760            let file = shared
15761                .write_workspace_file(workspace_id, path, expected_revision, text)
15762                .await?;
15763            Ok(NodeResponse::WorkspaceFileWritten { file })
15764        }
15765        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15766            shared.require_controller(connection_id, role)?;
15767            let file = shared.create_workspace_file(workspace_id, path).await?;
15768            Ok(NodeResponse::WorkspaceFileCreated { file })
15769        }
15770        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15771            shared.require_controller(connection_id, role)?;
15772            let entry = shared
15773                .create_workspace_directory(workspace_id.clone(), path)
15774                .await?;
15775            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15776        }
15777        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15778            if role != ClientRole::Operator {
15779                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15780            }
15781            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15782                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15783            }
15784            let page = shared
15785                .read_git_history(workspace_id.clone(), path, before, limit)
15786                .await?;
15787            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15788        }
15789        NodeRequest::ReadGitDiff { workspace_id, request } => {
15790            if role != ClientRole::Operator {
15791                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15792            }
15793            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15794            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15795        }
15796        NodeRequest::CatalogNativeSessions { route, limit } => {
15797            if role != ClientRole::Operator {
15798                return Err(failure(
15799                    NodeFailureCode::ObserverReadOnly,
15800                    "native session catalog requires an operator connection",
15801                ));
15802            }
15803            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15804                return Err(failure(
15805                    NodeFailureCode::InvalidRequest,
15806                    "native session catalog limit is invalid",
15807                ));
15808            }
15809            let (entries, summary) = shared
15810                .catalog_native_sessions(route.clone(), limit)
15811                .await?;
15812            Ok(NodeResponse::NativeSessionsCataloged {
15813                route,
15814                entries,
15815                summary: Some(summary),
15816            })
15817        }
15818        NodeRequest::PageNativeSessions {
15819            route,
15820            window,
15821            catalog_revision,
15822            recent_cutoff_unix_ms,
15823            after_selection_id,
15824            limit,
15825        } => {
15826            if role != ClientRole::Operator {
15827                return Err(failure(
15828                    NodeFailureCode::ObserverReadOnly,
15829                    "native session catalog paging requires an operator connection",
15830                ));
15831            }
15832            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15833                || after_selection_id
15834                    .as_deref()
15835                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15836            {
15837                return Err(failure(
15838                    NodeFailureCode::InvalidRequest,
15839                    "native session catalog page request is invalid",
15840                ));
15841            }
15842            let page = shared
15843                .page_native_sessions(
15844                    route.clone(),
15845                    window,
15846                    catalog_revision,
15847                    recent_cutoff_unix_ms,
15848                    after_selection_id,
15849                    limit,
15850                )
15851                .await?;
15852            Ok(NodeResponse::NativeSessionsPaged {
15853                route,
15854                page,
15855            })
15856        }
15857        NodeRequest::PreviewNativeSession {
15858            selection,
15859            message_limit,
15860        } => {
15861            if role != ClientRole::Operator {
15862                return Err(failure(
15863                    NodeFailureCode::ObserverReadOnly,
15864                    "native session preview requires an operator connection",
15865                ));
15866            }
15867            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15868                .contains(&message_limit)
15869                || selection.validate().is_err()
15870            {
15871                return Err(failure(
15872                    NodeFailureCode::InvalidRequest,
15873                    "native session preview request is invalid",
15874                ));
15875            }
15876            let preview = shared
15877                .preview_native_session(selection.clone(), message_limit)
15878                .await?;
15879            Ok(NodeResponse::NativeSessionPreviewed {
15880                selection,
15881                preview,
15882            })
15883        }
15884        NodeRequest::IndexNativeSession {
15885            selection,
15886            display_name,
15887        } => {
15888            shared.require_controller(connection_id, role)?;
15889            let record = shared
15890                .index_native_session(selection.clone(), display_name)
15891                .await?;
15892            Ok(NodeResponse::NativeSessionIndexed { selection, record })
15893        }
15894        NodeRequest::PreviewSessionRecord {
15895            record_id,
15896            message_limit,
15897        } => {
15898            if role != ClientRole::Operator {
15899                return Err(failure(
15900                    NodeFailureCode::ObserverReadOnly,
15901                    "native session preview requires an operator connection",
15902                ));
15903            }
15904            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15905                .contains(&message_limit)
15906            {
15907                return Err(failure(
15908                    NodeFailureCode::InvalidRequest,
15909                    "native session preview request is invalid",
15910                ));
15911            }
15912            let record = shared.record(&record_id)?;
15913            let working_directory = shared.workspace_root(&record.workspace_id)?;
15914            if !platform::roots_equal(
15915                &working_directory,
15916                windows_path_text(&record.canonical_root),
15917            ) {
15918                return Err(failure(
15919                    NodeFailureCode::SessionRecordConflict,
15920                    "managed session workspace root changed; refusing to preview another directory",
15921                ));
15922            }
15923            let identity = record.provider_session.clone().ok_or_else(|| {
15924                failure(
15925                    NodeFailureCode::BackendOperationFailed,
15926                    "native session preview is unavailable for this session record",
15927                )
15928            })?;
15929            if identity.key != ProviderSessionKey::SessionId {
15930                return Err(failure(
15931                    NodeFailureCode::BackendOperationFailed,
15932                    "native session preview is unavailable for this session identity",
15933                ));
15934            }
15935            let preview = shared
15936                .preview_native_session_by_session_id(
15937                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15938                )
15939                .await?;
15940            if preview.session_id != identity.id {
15941                return Err(failure(
15942                    NodeFailureCode::SessionRecordConflict,
15943                    "native session preview identity changed while loading",
15944                ));
15945            }
15946            shared.revalidate_session_record_preview(&record, &identity)?;
15947            let preview: gate4agent_types::SessionRecordPreview = preview.into();
15948            shared.publish(NodeEvent::SessionRecordHistorySummarized {
15949                record_id: record_id.clone(),
15950                summary: SessionHistorySummaryV1::from(&preview),
15951            });
15952            Ok(NodeResponse::SessionRecordPreviewed {
15953                record_id,
15954                preview,
15955            })
15956        }
15957        NodeRequest::AcquireController { lease_ms } => {
15958            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15959            Ok(NodeResponse::Controller { controller: Some(controller) })
15960        }
15961        NodeRequest::ReleaseController => {
15962            shared.release_controller(connection_id);
15963            Ok(NodeResponse::Controller { controller: shared.controller_state() })
15964        }
15965        NodeRequest::RegisterWorkspace { workspace_id, root } => {
15966            shared.require_controller(connection_id, role)?;
15967            let root = require_windows_path(root)?;
15968            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15969            let workspace = shared.register_workspace(workspace_id, root).await?;
15970            Ok(NodeResponse::WorkspaceRegistered { workspace })
15971        }
15972        NodeRequest::CreateStandaloneWorkspace {
15973            workspace_id,
15974            root,
15975            initial_branch,
15976        } => {
15977            shared.require_controller(connection_id, role)?;
15978            let root = require_windows_path(root)?;
15979            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15980            let workspace = shared
15981                .create_standalone_workspace(workspace_id, root, initial_branch)
15982                .await?;
15983            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15984        }
15985        NodeRequest::UnregisterWorkspace { workspace_id } => {
15986            shared.require_controller(connection_id, role)?;
15987            shared.reject_managed_reservation(Some(&workspace_id), None)?;
15988            shared.unregister_workspace(&workspace_id)?;
15989            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15990        }
15991        NodeRequest::CreateWorktree {
15992            source_workspace_id,
15993            workspace_id,
15994            target_root,
15995            branch,
15996            base,
15997        } => {
15998            shared.require_controller(connection_id, role)?;
15999            let target_root = require_windows_path(target_root)?;
16000            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16001            let (worktree, workspace) = shared
16002                .create_worktree(
16003                    source_workspace_id,
16004                    workspace_id,
16005                    target_root,
16006                    branch,
16007                    base,
16008                )
16009                .await?;
16010            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16011        }
16012        NodeRequest::RemoveWorktree {
16013            source_workspace_id,
16014            target_root,
16015        } => {
16016            shared.require_controller(connection_id, role)?;
16017            let native_target_root = require_windows_path(target_root.clone())?;
16018            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16019            let workspace_id = shared
16020                .remove_worktree(source_workspace_id, native_target_root)
16021                .await?;
16022            Ok(NodeResponse::WorktreeRemoved {
16023                target_root,
16024                workspace_id,
16025            })
16026        }
16027        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16028            shared.require_controller(connection_id, role)?;
16029            if !terminal_size.is_valid() {
16030                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16031            }
16032            if let Some(prompt) = initial_prompt.as_deref() {
16033                validate_node_text("spawn initial prompt", prompt)?;
16034            }
16035            let session = shared
16036                .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
16037                .await?;
16038            Ok(NodeResponse::SpawnAccepted { session })
16039        }
16040        NodeRequest::SpawnSpec { spec } => {
16041            shared.require_controller(connection_id, role)?;
16042            let receipt = shared.spawn_from_spec(spec).await?;
16043            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16044        }
16045        NodeRequest::SpawnManagedWorktree { request } => {
16046            shared.require_controller(connection_id, role)?;
16047            let receipt = shared.spawn_managed_worktree(request).await?;
16048            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16049        }
16050        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16051            shared.require_controller(connection_id, role)?;
16052            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16053            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16054        }
16055        NodeRequest::CleanupManagedWorktree { lease_id } => {
16056            shared.require_controller(connection_id, role)?;
16057            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16058            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16059        }
16060        NodeRequest::DiscoverHistory { session, limit } => {
16061            controlled_session(shared, connection_id, role, &session)?;
16062            let candidates = shared.discover_history(&session, limit).await?;
16063            Ok(NodeResponse::HistoryDiscovered {
16064                session,
16065                candidates,
16066            })
16067        }
16068        NodeRequest::LoadHistory {
16069            session,
16070            candidate_id,
16071        } => {
16072            controlled_session(shared, connection_id, role, &session)?;
16073            let loaded = shared
16074                .load_history(&session, candidate_id)
16075                .await?;
16076            Ok(NodeResponse::HistoryLoaded {
16077                session,
16078                session_id: loaded.session_id,
16079                message_count: loaded.message_count,
16080                completed_turn_count: loaded.completed_turn_count,
16081            })
16082        }
16083        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16084            shared.require_controller(connection_id, role)?;
16085            let context = shared
16086                .export_context_pack_for_session_record(&record_id, &session, false)
16087                .await?;
16088            Ok(NodeResponse::ContextPackForSessionRecordExported {
16089                record_id,
16090                session,
16091                context,
16092            })
16093        }
16094        NodeRequest::ExportContextPack { session } => {
16095            controlled_session(shared, connection_id, role, &session)?;
16096            let context = shared.export_context_pack(&session).await?;
16097            Ok(NodeResponse::ContextPackExported { context })
16098        }
16099        NodeRequest::ForgetContextPack { context_id } => {
16100            shared.require_controller(connection_id, role)?;
16101            shared.forget_context_pack(&context_id)?;
16102            Ok(NodeResponse::ContextPackForgotten { context_id })
16103        }
16104        NodeRequest::ResolveDurableContextPack { context_id } => {
16105            // Read-only, idempotent, no controller lease required: this lets
16106            // a caller preflight "is this durably exported pack still here"
16107            // without holding the Node's mutation controller.
16108            let context = shared
16109                .context_catalog
16110                .read()
16111                .unwrap_or_else(|poisoned| poisoned.into_inner())
16112                .get(&context_id)
16113                .map(|pack| pack.receipt().clone())
16114                .ok_or_else(|| {
16115                    failure(
16116                        NodeFailureCode::UnknownContextPack,
16117                        "context pack is not present",
16118                    )
16119                })?;
16120            Ok(NodeResponse::DurableContextPackResolved { context })
16121        }
16122        NodeRequest::ReadContextPack { digest } => {
16123            // Read-only fetch of the pack's own bytes, keyed by content digest
16124            // rather than the caller's `SpawnContextId`: same no-controller-lease
16125            // rationale as `ResolveDurableContextPack` above, one step further
16126            // down the same catalog.
16127            let (id, byte_len, bytes) = shared
16128                .context_catalog
16129                .read()
16130                .unwrap_or_else(|poisoned| poisoned.into_inner())
16131                .get_by_digest(&digest)
16132                .map(|pack| {
16133                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16134                })
16135                .ok_or_else(|| {
16136                    failure(
16137                        NodeFailureCode::UnknownContextPack,
16138                        &format!("context pack {digest} not on this node"),
16139                    )
16140                })?;
16141            tracing::debug!(
16142                digest = digest.as_str(),
16143                byte_len,
16144                "read context pack bytes",
16145            );
16146            Ok(NodeResponse::ContextPackBytesRead {
16147                pack: ContextPackBytesRead {
16148                    digest,
16149                    id,
16150                    byte_len,
16151                    bytes,
16152                },
16153            })
16154        }
16155        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16156            let provider = controlled_session(shared, connection_id, role, &session)?;
16157            if let Some(prompt) = initial_prompt.as_deref() {
16158                validate_node_text("resume initial prompt", prompt)?;
16159            }
16160            let runtime_requirement = if initial_prompt.is_some() {
16161                ProviderRuntimeRequirement::ResumeWithPrompt
16162            } else {
16163                ProviderRuntimeRequirement::Resume
16164            };
16165            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16166            let runtime_policy = shared
16167                .admit_provider_runtime(&provider, runtime_requirement)
16168                .await?;
16169            let working_directory = shared.bound_workspace_root(&session)?;
16170            let request = ResumeLaunchRequest {
16171                working_directory,
16172                terminal_size,
16173                initial_prompt,
16174            };
16175            request
16176                .validate()
16177                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16178            let command = shared.prepare_command(ControlCommand::Resume {
16179                instance_id: session.session.instance_id,
16180                target: ResumeTarget::CurrentProvider,
16181                runtime_policy,
16182                request,
16183            });
16184            shared.arm_resume(&session, command.id, runtime_policy)?;
16185            let dispatch = shared.dispatch_envelope(command);
16186            if let Err(error) = dispatch {
16187                shared.clear_armed_resume(&session);
16188                return Err(error);
16189            }
16190            Ok(NodeResponse::Accepted)
16191        }
16192        NodeRequest::RenameSessionRecord {
16193            record_id,
16194            display_name,
16195        } => {
16196            shared.require_controller(connection_id, role)?;
16197            let record = shared.rename_session_record(&record_id, display_name)?;
16198            Ok(NodeResponse::SessionRecordUpdated { record })
16199        }
16200        NodeRequest::SetSessionTask {
16201            record_id,
16202            expected_revision,
16203            target,
16204        } => {
16205            shared.require_controller(connection_id, role)?;
16206            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16207            Ok(NodeResponse::SessionRecordUpdated { record })
16208        }
16209        NodeRequest::IndexProviderSession {
16210            workspace_id,
16211            provider,
16212            identity,
16213            display_name,
16214        } => {
16215            shared.require_controller(connection_id, role)?;
16216            let record = shared.index_provider_session(
16217                workspace_id,
16218                provider,
16219                identity,
16220                display_name,
16221            )?;
16222            Ok(NodeResponse::ProviderSessionIndexed { record })
16223        }
16224        NodeRequest::ResumeSessionRecord {
16225            record_id,
16226            terminal_size,
16227            initial_prompt,
16228        } => {
16229            shared.require_controller(connection_id, role)?;
16230            if let Some(prompt) = initial_prompt.as_deref() {
16231                validate_node_text("managed resume initial prompt", prompt)?;
16232            }
16233            let (record, session) = shared
16234                .resume_session_record(
16235                    &record_id,
16236                    terminal_size,
16237                    initial_prompt,
16238                )
16239                .await?;
16240            Ok(NodeResponse::SessionRecordResumed { record, session })
16241        }
16242        NodeRequest::ForgetSessionRecord { record_id } => {
16243            shared.require_controller(connection_id, role)?;
16244            shared.forget_session_record(&record_id).await?;
16245            Ok(NodeResponse::SessionRecordForgotten { record_id })
16246        }
16247        NodeRequest::Prompt { session, text } => {
16248            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16249            validate_node_text("prompt", &text)?;
16250            shared.require_session_runtime_policy(
16251                &session,
16252                ProviderRuntimeRequirement::SemanticPrompt,
16253            )?;
16254            let framing = prompt_framing(&agent_id);
16255            shared
16256                .dispatch_input_bounded(
16257                    &session,
16258                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16259                )
16260                .await?;
16261            Ok(NodeResponse::Accepted)
16262        }
16263        NodeRequest::Paste { session, text } => {
16264            controlled_session(shared, connection_id, role, &session)?;
16265            validate_node_text("paste", &text)?;
16266            shared.require_session_runtime_policy(
16267                &session,
16268                ProviderRuntimeRequirement::SemanticPrompt,
16269            )?;
16270            shared
16271                .dispatch_input_bounded(
16272                    &session,
16273                    InputAction::InsertDraft(PromptPayload {
16274                        text,
16275                        framing: PromptFraming::BracketedPaste,
16276                    }),
16277                )
16278                .await?;
16279            Ok(NodeResponse::Accepted)
16280        }
16281        NodeRequest::Input { session, text } => {
16282            controlled_session(shared, connection_id, role, &session)?;
16283            validate_node_text("terminal input", &text)?;
16284            shared
16285                .dispatch_input_bounded(
16286                    &session,
16287                    InputAction::TerminalText(TerminalText { text }),
16288                )
16289                .await?;
16290            Ok(NodeResponse::Accepted)
16291        }
16292        NodeRequest::TerminalBytes { session, bytes } => {
16293            controlled_session(shared, connection_id, role, &session)?;
16294            let action = terminal_bytes_action(bytes)?;
16295            shared
16296                .dispatch_input_bounded(&session, action)
16297                .await?;
16298            Ok(NodeResponse::Accepted)
16299        }
16300        NodeRequest::TerminalControl { session, control } => {
16301            controlled_session(shared, connection_id, role, &session)?;
16302            shared
16303                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16304                .await?;
16305            Ok(NodeResponse::Accepted)
16306        }
16307        NodeRequest::Resize { session, size } => {
16308            controlled_session(shared, connection_id, role, &session)?;
16309            if !size.is_valid() {
16310                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16311            }
16312            shared.dispatch_resize_bounded(&session, size).await?;
16313            Ok(NodeResponse::Accepted)
16314        }
16315        NodeRequest::Interrupt { session } => {
16316            controlled_session(shared, connection_id, role, &session)?;
16317            shared
16318                .dispatch_input_bounded(
16319                    &session,
16320                    InputAction::TerminalControl(TerminalControl::Interrupt),
16321                )
16322                .await?;
16323            Ok(NodeResponse::Accepted)
16324        }
16325        NodeRequest::Stop { session, force } => {
16326            controlled_session(shared, connection_id, role, &session)?;
16327            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16328            Ok(NodeResponse::Accepted)
16329        }
16330        NodeRequest::Remove { session } => {
16331            controlled_session(shared, connection_id, role, &session)?;
16332            shared.remove_session(&session).await?;
16333            Ok(NodeResponse::Accepted)
16334        }
16335        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16336            shared.require_controller(connection_id, role)?;
16337            let snapshot = shared.require_acp_session(&session)?;
16338            let interaction = snapshot
16339                .provider
16340                .interactions
16341                .iter()
16342                .find(|interaction| {
16343                    interaction_correlation(
16344                        session.session.instance_id,
16345                        session.session.generation,
16346                        interaction.id.0,
16347                    ) == correlation_id
16348                })
16349                .ok_or_else(|| failure(
16350                    NodeFailureCode::InvalidRequest,
16351                    "unknown interaction correlation",
16352                ))?;
16353            if interaction.status != ProviderInteractionStatus::Pending {
16354                return Err(failure(
16355                    NodeFailureCode::BackendOperationFailed,
16356                    "interaction has already been resolved",
16357                ));
16358            }
16359            response
16360                .validate_for(interaction.interaction_kind)
16361                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16362            let interaction_id = interaction.id;
16363            let response_kind = response.kind();
16364            let response_outcome = response.outcome();
16365            shared.dispatch(ControlCommand::ResolveInteraction {
16366                instance_id: session.session.instance_id,
16367                generation: session.session.generation,
16368                interaction_id,
16369                response,
16370            })?;
16371            // The one line that lets an operator compare what THIS node
16372            // recorded against what the provider itself reports doing --
16373            // measured live on a kimi run where our side resolved `true`
16374            // while the agent reported "rejected" and neither layer had a
16375            // line to line up against the other.
16376            tracing::info!(
16377                node_id = %shared.node_id,
16378                session = ?session.session,
16379                correlation_id = %correlation_id,
16380                interaction_id = interaction_id.0,
16381                response_kind = ?response_kind,
16382                outcome = ?response_outcome,
16383                "interaction resolved",
16384            );
16385            Ok(NodeResponse::Accepted)
16386        }
16387        NodeRequest::SetSessionMode { session, mode_id } => {
16388            shared.require_controller(connection_id, role)?;
16389            shared.require_acp_session(&session)?;
16390            shared.dispatch(ControlCommand::SetSessionMode {
16391                instance_id: session.session.instance_id,
16392                mode_id,
16393            })?;
16394            Ok(NodeResponse::Accepted)
16395        }
16396        NodeRequest::SetSessionConfigOption {
16397            session,
16398            option_id,
16399            value_json,
16400        } => {
16401            shared.require_controller(connection_id, role)?;
16402            shared.require_acp_session(&session)?;
16403            shared.dispatch(ControlCommand::SetSessionConfigOption {
16404                instance_id: session.session.instance_id,
16405                option_id,
16406                value_json,
16407            })?;
16408            Ok(NodeResponse::Accepted)
16409        }
16410        NodeRequest::SetSessionModel { session, model_id } => {
16411            shared.require_controller(connection_id, role)?;
16412            shared.require_acp_session(&session)?;
16413            shared.dispatch(ControlCommand::SetSessionModel {
16414                instance_id: session.session.instance_id,
16415                model_id,
16416            })?;
16417            Ok(NodeResponse::Accepted)
16418        }
16419        NodeRequest::Shutdown => {
16420            shared.require_controller(connection_id, role)?;
16421            shared.begin_shutdown_locked().await?;
16422            Ok(NodeResponse::ShuttingDown)
16423        }
16424    }
16425}
16426
16427fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16428    shared.require_controller(connection_id, role)?;
16429    shared.validate_address(session)
16430}
16431
16432/// Selects the sole history candidate matching `provider_session_id` when
16433/// the managed record carries a verified identity, or the sole candidate in
16434/// the discovered set at all when it does not (see
16435/// `export_context_pack_for_session_record_inner`'s identity match). Either
16436/// way, more than one match refuses instead of guessing which one is exact.
16437fn select_history_candidate(
16438    candidates: &[HistoryCandidateSummary],
16439    provider_session_id: Option<&str>,
16440) -> Result<String, NodeFailure> {
16441    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16442        Some(id) => candidate.session_id_hint == id,
16443        None => true,
16444    });
16445    let candidate = matches.next().ok_or_else(|| {
16446        failure(
16447            NodeFailureCode::SessionRecordConflict,
16448            if provider_session_id.is_some() {
16449                "no history candidate matches the managed provider session"
16450            } else {
16451                "no history candidate exists for the managed session"
16452            },
16453        )
16454    })?;
16455    if matches.next().is_some() {
16456        return Err(failure(
16457            NodeFailureCode::SessionRecordConflict,
16458            if provider_session_id.is_some() {
16459                "history identity is ambiguous for the managed provider session"
16460            } else {
16461                "history is ambiguous without a verified provider session identity"
16462            },
16463        ));
16464    }
16465    Ok(candidate.id.clone())
16466}
16467
16468fn resolved_spawn_profile_summary(
16469    profile: &SpawnProfileDefaults,
16470    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16471) -> Option<SpawnProfileSummary> {
16472    let environment_profile = match profile.environment_profile_id.as_ref() {
16473        None => None,
16474        Some(profile_id) => {
16475            let binding = environment_profiles.get(profile_id)?;
16476            if binding.provider != profile.provider
16477                || binding.native_profile_id(profile.mode).is_none()
16478            {
16479                return None;
16480            }
16481            Some(ResolvedEnvironmentProfileReceipt {
16482                profile_id: binding.id.clone(),
16483                profile_revision: binding.revision.clone(),
16484                network_allowlist: None,
16485                browser_profile_id: None,
16486            })
16487        }
16488    };
16489    Some(SpawnProfileSummary {
16490        id: profile.profile_id.clone(),
16491        revision: profile.revision.clone(),
16492        environment_profile,
16493    })
16494}
16495
16496/// True if `current` is an acceptable version of `expected` at export
16497/// revalidation/commit time.
16498///
16499/// When `allow_clean_detachment` is `false` (the on-demand,
16500/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16501/// be byte-identical to `expected` and still actively bound to `session` —
16502/// the original, unchanged invariant.
16503///
16504/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16505/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16506/// export reacts to produces on its own: `state` Live -> Dormant,
16507/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16508/// advancing. Every other field — including `provider_session`, `context`,
16509/// `bundle`, `task_binding` — must still match exactly. A record that changed
16510/// in any other way (rebind, a different active session, an identity
16511/// conflict, capacity eviction) is rejected either way; this widens nothing
16512/// about which sessions are eligible, only how the record's own expected
16513/// detachment is tolerated mid-export.
16514fn session_record_export_target_matches(
16515    expected: &ManagedSessionRecord,
16516    current: &ManagedSessionRecord,
16517    session: &SessionAddress,
16518    allow_clean_detachment: bool,
16519) -> bool {
16520    if current == expected && current.active_session.as_ref() == Some(session) {
16521        return current.state == ManagedSessionState::Live;
16522    }
16523    allow_clean_detachment
16524        && current.state == ManagedSessionState::Dormant
16525        && current.active_session.is_none()
16526        && current.record_id == expected.record_id
16527        && current.display_name == expected.display_name
16528        && current.provider == expected.provider
16529        && current.mode == expected.mode
16530        && current.workspace_id == expected.workspace_id
16531        && current.canonical_root == expected.canonical_root
16532        && current.provider_session == expected.provider_session
16533        && current.environment_profile == expected.environment_profile
16534        && current.bundle == expected.bundle
16535        && current.context_id == expected.context_id
16536        && current.context == expected.context
16537        && current.exported_context == expected.exported_context
16538        && current.task_binding == expected.task_binding
16539        && current.created_at_unix_ms == expected.created_at_unix_ms
16540        && current.last_error == expected.last_error
16541}
16542
16543fn session_record_context_export_binding_is_exact(
16544    expected: &ManagedSessionRecord,
16545    current: &ManagedSessionRecord,
16546    identity: Option<&ProviderSessionIdentity>,
16547    session: &SessionAddress,
16548    binding: &SessionBinding,
16549    provider: &AgentId,
16550    current_root: &str,
16551    allow_clean_detachment: bool,
16552) -> bool {
16553    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16554        && current.provider_session.as_ref() == identity
16555        && current.workspace_id == session.workspace_id
16556        && provider == &current.provider
16557        && binding.workspace_id == session.workspace_id
16558        && binding.generation == session.session.generation
16559        && binding.record_id.as_ref() == Some(&current.record_id)
16560        && platform::roots_equal(
16561            current_root,
16562            windows_path_text(&current.canonical_root),
16563        )
16564}
16565
16566fn session_record_context_export_source_is_usable(
16567    record: &ManagedSessionRecord,
16568    status: &SessionStatus,
16569    allow_clean_detachment: bool,
16570) -> bool {
16571    (record.state == ManagedSessionState::Live
16572        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16573        && context_pack_source_status_is_usable(status)
16574}
16575
16576fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16577    PromptFraming::BracketedPaste
16578}
16579
16580fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16581    if text.len() > MAX_NODE_TEXT_BYTES {
16582        return Err(failure(
16583            NodeFailureCode::InvalidRequest,
16584            &format!(
16585                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16586                text.len(),
16587            ),
16588        ));
16589    }
16590    Ok(())
16591}
16592
16593fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16594    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16595        return Err(failure(
16596            NodeFailureCode::InvalidRequest,
16597            &format!(
16598                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16599                bytes.len(),
16600            ),
16601        ));
16602    }
16603    Ok(())
16604}
16605
16606fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16607    validate_terminal_bytes(&bytes)?;
16608    Ok(InputAction::TerminalBytes(bytes))
16609}
16610
16611fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16612    if root.is_empty()
16613        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16614        || root.chars().any(char::is_control)
16615    {
16616        return Err(failure(
16617            NodeFailureCode::InvalidWorkspaceRoot,
16618            &format!(
16619                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16620            ),
16621        ));
16622    }
16623    Ok(())
16624}
16625
16626fn canonical_standalone_workspace_candidate(
16627    workspace_id: &WorkspaceId,
16628    root: &str,
16629) -> Result<String, NodeServerError> {
16630    let path = Path::new(root);
16631    match std::fs::symlink_metadata(path) {
16632        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16633            .map(|workspace| workspace.canonical_root),
16634        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16635            if !path.is_absolute() {
16636                return Err(NodeServerError::InvalidWorkspaceRoot {
16637                    workspace_id: workspace_id.clone(),
16638                    path: root.to_owned(),
16639                    message: "path is not absolute".to_owned(),
16640                });
16641            }
16642            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16643                workspace_id: workspace_id.clone(),
16644                path: root.to_owned(),
16645                message: "path has no standalone directory name".to_owned(),
16646            })?;
16647            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16648                workspace_id: workspace_id.clone(),
16649                path: root.to_owned(),
16650                message: "path has no parent directory".to_owned(),
16651            })?;
16652            let canonical = std::fs::canonicalize(parent)
16653                .map(|parent| parent.join(name))
16654                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16655                    workspace_id: workspace_id.clone(),
16656                    path: root.to_owned(),
16657                    message: error.to_string(),
16658                })?;
16659            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16660                NodeServerError::InvalidWorkspaceRoot {
16661                    workspace_id: workspace_id.clone(),
16662                    path: path.to_string_lossy().into_owned(),
16663                    message: "canonical path is not valid Unicode".to_owned(),
16664                }
16665            })?;
16666            let canonical_root = platform::normalize_canonical_root(canonical_root);
16667            validate_workspace_root(workspace_id, &canonical_root)?;
16668            Ok(canonical_root)
16669        }
16670        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16671            workspace_id: workspace_id.clone(),
16672            path: root.to_owned(),
16673            message: error.to_string(),
16674        }),
16675    }
16676}
16677
16678fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16679    let Some(revision) = revision else {
16680        return Ok(());
16681    };
16682    if revision.is_empty()
16683        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16684        || revision.chars().any(char::is_control)
16685    {
16686        return Err(failure(
16687            NodeFailureCode::InvalidRequest,
16688            &format!(
16689                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16690            ),
16691        ));
16692    }
16693    Ok(())
16694}
16695
16696fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16697    let code = match error.kind {
16698        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16699        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16700        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16701        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16702        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16703        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16704        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16705    };
16706    failure(code, &error.message)
16707}
16708
16709fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16710    let code = match error.kind {
16711        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16712        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16713        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16714            NodeFailureCode::BackendOperationFailed
16715        }
16716        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16717            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16718        }
16719    };
16720    failure(code, error.message)
16721}
16722
16723fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16724    let (code, message) = match error.kind {
16725        GitWorktreeErrorKind::Invalid => (
16726            NodeFailureCode::InvalidRequest,
16727            "managed Git input is invalid",
16728        ),
16729        GitWorktreeErrorKind::NotRepository => (
16730            NodeFailureCode::NotGitRepository,
16731            "managed source is not a Git repository",
16732        ),
16733        GitWorktreeErrorKind::Conflict => (
16734            NodeFailureCode::WorktreeConflict,
16735            "managed Git identity conflicts with existing repository state",
16736        ),
16737        GitWorktreeErrorKind::Protected => (
16738            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16739            "managed worktree ownership validation failed",
16740        ),
16741        GitWorktreeErrorKind::Dirty => (
16742            NodeFailureCode::WorktreeDirty,
16743            "managed worktree contains uncommitted changes",
16744        ),
16745        GitWorktreeErrorKind::Locked => (
16746            NodeFailureCode::WorktreeLocked,
16747            "managed worktree is locked",
16748        ),
16749        GitWorktreeErrorKind::Failed => (
16750            NodeFailureCode::BackendOperationFailed,
16751            "managed Git operation failed",
16752        ),
16753    };
16754    NodeFailure { code, message: message.to_owned() }
16755}
16756
16757fn managed_spawn_receipt(
16758    resolved: &ResolvedSpawnSpec,
16759    incarnation_id: NodeIncarnationId,
16760    session: SessionAddress,
16761    lease: ManagedWorktreeLeaseSnapshot,
16762    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16763    bundle: Option<ResolvedBundleReceipt>,
16764    context: Option<ResolvedContextPackReceipt>,
16765) -> ManagedWorktreeSpawnReceipt {
16766    let mut managed = resolved.clone();
16767    managed.target.worktree_id = Some(lease.workspace_id.clone());
16768    ManagedWorktreeSpawnReceipt {
16769        spawn: managed.receipt_with_materialization(
16770            incarnation_id,
16771            session,
16772            environment_profile,
16773            bundle,
16774            context,
16775        ),
16776        lease,
16777    }
16778}
16779
16780fn validate_selected_worktree(
16781    source_root: &str,
16782    selected_root: &str,
16783    worktrees: &[NativeGitWorktreeSnapshot],
16784) -> Result<(), NodeFailure> {
16785    if !worktrees
16786        .iter()
16787        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16788    {
16789        return Err(failure(
16790            NodeFailureCode::WorktreeProtected,
16791            "source workspace is not an authoritative non-bare Git worktree root",
16792        ));
16793    }
16794    if !worktrees.iter().any(|worktree| {
16795        worktree_paths_equal(&worktree.path, selected_root)
16796            && !worktree.is_main
16797            && !worktree.is_bare
16798    }) {
16799        return Err(failure(
16800            NodeFailureCode::WorktreeProtected,
16801            "selected workspace is not an eligible Git worktree in the source repository",
16802        ));
16803    }
16804    Ok(())
16805}
16806
16807fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16808    let code = match error.kind() {
16809        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16810        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16811            NodeFailureCode::RepositoryPathUnsafe
16812        }
16813        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16814        WorkspaceFileReadErrorKind::NotRegularFile => {
16815            NodeFailureCode::RepositoryFileNotRegular
16816        }
16817        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16818            NodeFailureCode::RepositoryFileReadFailed
16819        }
16820        WorkspaceFileReadErrorKind::RevisionConflict => {
16821            NodeFailureCode::RepositoryFileRevisionConflict
16822        }
16823        WorkspaceFileReadErrorKind::AlreadyExists
16824        | WorkspaceFileReadErrorKind::ParentNotFound
16825        | WorkspaceFileReadErrorKind::ParentNotDirectory
16826        | WorkspaceFileReadErrorKind::Canceled => {
16827            NodeFailureCode::RepositoryFileReadFailed
16828        }
16829    };
16830    failure(code, "repository file read failed")
16831}
16832
16833fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16834    let code = match error.kind() {
16835        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16836        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16837            NodeFailureCode::RepositoryPathUnsafe
16838        }
16839        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16840        WorkspaceFileReadErrorKind::NotRegularFile => {
16841            NodeFailureCode::RepositoryFileNotRegular
16842        }
16843        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16844            NodeFailureCode::RepositoryFileWriteFailed
16845        }
16846        WorkspaceFileReadErrorKind::RevisionConflict => {
16847            NodeFailureCode::RepositoryFileRevisionConflict
16848        }
16849        WorkspaceFileReadErrorKind::AlreadyExists
16850        | WorkspaceFileReadErrorKind::ParentNotFound
16851        | WorkspaceFileReadErrorKind::ParentNotDirectory
16852        | WorkspaceFileReadErrorKind::Canceled => {
16853            NodeFailureCode::RepositoryFileWriteFailed
16854        }
16855    };
16856    failure(code, "repository file write failed")
16857}
16858
16859async fn settle_workspace_entry_create<T>(
16860    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16861    commit_state: Arc<AtomicU8>,
16862    deadline: Duration,
16863    timeout_message: &'static str,
16864    task_failure_message: &'static str,
16865) -> Result<T, NodeFailure> {
16866    let joined = match timeout(deadline, &mut task).await {
16867        Ok(joined) => joined,
16868        Err(_) => {
16869            if commit_state
16870                .compare_exchange(
16871                    WORKSPACE_ENTRY_CREATE_PENDING,
16872                    WORKSPACE_ENTRY_CREATE_CANCELED,
16873                    Ordering::AcqRel,
16874                    Ordering::Acquire,
16875                )
16876                .is_ok()
16877            {
16878                return Err(failure(
16879                    NodeFailureCode::RepositoryEntryCreateTimedOut,
16880                    timeout_message,
16881                ));
16882            }
16883            debug_assert_eq!(
16884                commit_state.load(Ordering::Acquire),
16885                WORKSPACE_ENTRY_CREATE_COMMITTING,
16886            );
16887            task.await
16888        }
16889    };
16890    joined
16891        .map_err(|_| {
16892            failure(
16893                NodeFailureCode::RepositoryEntryCreateFailed,
16894                task_failure_message,
16895            )
16896        })?
16897        .map_err(workspace_entry_create_failure)
16898}
16899
16900fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16901    let code = match error.kind() {
16902        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16903        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16904            NodeFailureCode::RepositoryPathUnsafe
16905        }
16906        WorkspaceFileReadErrorKind::AlreadyExists => {
16907            NodeFailureCode::RepositoryEntryAlreadyExists
16908        }
16909        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16910            NodeFailureCode::RepositoryParentNotFound
16911        }
16912        WorkspaceFileReadErrorKind::ParentNotDirectory
16913        | WorkspaceFileReadErrorKind::NotRegularFile => {
16914            NodeFailureCode::RepositoryParentNotDirectory
16915        }
16916        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16917            NodeFailureCode::RepositoryEntryCreateFailed
16918        }
16919        WorkspaceFileReadErrorKind::RevisionConflict => {
16920            NodeFailureCode::RepositoryEntryCreateFailed
16921        }
16922        WorkspaceFileReadErrorKind::Canceled => {
16923            NodeFailureCode::RepositoryEntryCreateTimedOut
16924        }
16925    };
16926    failure(code, "repository entry create failed")
16927}
16928
16929fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16930    let value = digest(&SHA256, bytes)
16931        .as_ref()
16932        .iter()
16933        .map(|byte| format!("{byte:02x}"))
16934        .collect();
16935    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16936}
16937
16938fn managed_spawn_request_digest_v2(
16939    request: &ManagedWorktreeSpawnRequestV2,
16940) -> Result<String, NodeFailure> {
16941    let encoded = serde_json::to_vec(request).map_err(|_| {
16942        failure(
16943            NodeFailureCode::InvalidRequest,
16944            "managed worktree V2 request could not be canonicalized",
16945        )
16946    })?;
16947    let mut value = String::with_capacity(71);
16948    value.push_str("sha256:");
16949    for byte in digest(&SHA256, &encoded).as_ref() {
16950        use std::fmt::Write as _;
16951        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16952    }
16953    Ok(value)
16954}
16955
16956fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16957    let code = match error {
16958        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16959        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16960        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16961        NativeSessionPreviewError::UnsupportedProvider
16962        | NativeSessionPreviewError::PreviewUnavailable
16963        | NativeSessionPreviewError::SessionNotFound
16964        | NativeSessionPreviewError::AmbiguousSession => {
16965            NodeFailureCode::BackendOperationFailed
16966        }
16967    };
16968    failure(code, "native session preview failed")
16969}
16970
16971fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16972    let code = match error.kind() {
16973        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16974        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16975    };
16976    failure(code, "host directory browse failed")
16977}
16978
16979fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16980    let code = match error {
16981        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16982        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16983        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16984            NodeFailureCode::DeliveryStageConflict
16985        }
16986        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16987        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16988            NodeFailureCode::DeliveryChunkOutOfOrder
16989        }
16990        DeliveryStoreError::BlobDigestMismatch => {
16991            NodeFailureCode::DeliveryBlobDigestMismatch
16992        }
16993        DeliveryStoreError::BundleDigestMismatch => {
16994            NodeFailureCode::DeliveryBundleDigestMismatch
16995        }
16996        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16997        DeliveryStoreError::Unavailable
16998        | DeliveryStoreError::Corrupt
16999        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17000    };
17001    failure(code, "delivery operation failed")
17002}
17003
17004fn validate_workspace_root(
17005    workspace_id: &WorkspaceId,
17006    canonical_root: &str,
17007) -> Result<(), NodeServerError> {
17008    #[cfg(windows)]
17009    if canonical_root.starts_with(r"\\") {
17010        return Err(NodeServerError::InvalidWorkspaceRoot {
17011            workspace_id: workspace_id.clone(),
17012            path: canonical_root.to_owned(),
17013            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17014        });
17015    }
17016    if !platform::workspace_root_supported(canonical_root)
17017        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17018    {
17019        return Err(NodeServerError::InvalidWorkspaceRoot {
17020            workspace_id: workspace_id.clone(),
17021            path: canonical_root.to_owned(),
17022            message: "canonical path must be absolute, bounded, and free of control characters"
17023                .to_owned(),
17024        });
17025    }
17026    Ok(())
17027}
17028
17029fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17030    NodeFailure {
17031        code,
17032        message: node_failure_category(code).to_owned(),
17033    }
17034}
17035
17036fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17037    let code = match error {
17038        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17039        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17040        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17041        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17042        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17043        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17044        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17045        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17046        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17047    };
17048    failure(code, "harness MCP proxy operation failed")
17049}
17050
17051/// Numbers each harness-MCP overlay this process constructs, in
17052/// construction order, so two overlays built back to back in one node process
17053/// still get distinct trace files.
17054static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17055
17056/// Builds the optional trace environment entry of one harness-MCP overlay.
17057/// The caller's launch description names both the entry and the node-process
17058/// variable that opts in: when that variable holds a non-empty directory, the
17059/// entry names a trace file under it. Returns `None` when the launch has no
17060/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17061/// the directory; that is the operator's job.
17062fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17063    let trace = launch.trace.as_ref()?;
17064    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17065    if trace_dir.is_empty() {
17066        return None;
17067    }
17068    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17069    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17070    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17071}
17072
17073fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17074    let remaining = deadline.saturating_duration_since(Instant::now());
17075    if remaining.is_zero() {
17076        Err(failure(
17077            NodeFailureCode::SpawnDeadlineExceeded,
17078            "spawn deadline elapsed",
17079        ))
17080    } else {
17081        Ok(remaining)
17082    }
17083}
17084
17085fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17086    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17087    match deadline {
17088        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17089        None => Ok(bounded),
17090    }
17091}
17092
17093fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17094    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17095        failure(
17096            NodeFailureCode::SpawnDeadlineExceeded,
17097            "spawn dispatch exceeded the spawn deadline",
17098        )
17099    } else {
17100        error
17101    }
17102}
17103
17104fn spawn_runtime_capabilities(
17105    capabilities: &SpawnRequiredCapabilities,
17106) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17107    capabilities
17108        .iter()
17109        .map(|capability| match capability.as_str() {
17110            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17111            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17112            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17113            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17114                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17115            }
17116            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17117            _ => Err(failure(
17118                NodeFailureCode::UnsupportedSpawnCapability,
17119                "spawn required capability is unknown",
17120            )),
17121        })
17122        .collect()
17123}
17124
17125fn persistence_failure(_error: io::Error) -> NodeFailure {
17126    NodeFailure {
17127        code: NodeFailureCode::BackendOperationFailed,
17128        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17129    }
17130}
17131
17132fn node_failure_category(code: NodeFailureCode) -> &'static str {
17133    match code {
17134        NodeFailureCode::InvalidRequest => "invalid-request",
17135        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17136        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17137        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17138        NodeFailureCode::ReservationConflict => "reservation-conflict",
17139        NodeFailureCode::ReservationExpired => "reservation-expired",
17140        NodeFailureCode::BindingMismatch => "binding-mismatch",
17141        NodeFailureCode::NotActivated => "not-activated",
17142        NodeFailureCode::CallNotFound => "call-not-found",
17143        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17144        NodeFailureCode::ResponseTooLarge => "response-too-large",
17145        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17146        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17147        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17148        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17149        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17150        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17151        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17152        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17153        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17154        NodeFailureCode::Unauthorized => "unauthorized",
17155        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17156        NodeFailureCode::ControllerBusy => "controller-busy",
17157        NodeFailureCode::ControllerRequired => "controller-required",
17158        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17159        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17160        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17161        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17162        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17163        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17164        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17165        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17166        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17167        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17168        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17169        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17170        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17171        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17172        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17173        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17174        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17175        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17176        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17177        NodeFailureCode::GitReadFailed => "git-read-failed",
17178        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17179        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17180        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17181        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17182        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17183        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17184        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17185        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17186        NodeFailureCode::TurnInFlight => "turn-in-flight",
17187        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17188        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17189        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17190            "unsupported-network-allowlist-mapping"
17191        }
17192        NodeFailureCode::BrowserStationProbeUnavailable => {
17193            "browser-station-probe-unavailable"
17194        }
17195        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17196        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17197        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17198            "environment-profile-binding-mismatch"
17199        }
17200        NodeFailureCode::UnknownBundle => "unknown-bundle",
17201        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17202        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17203        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17204        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17205        NodeFailureCode::ContextPackMaterializationFailed => {
17206            "context-pack-materialization-failed"
17207        }
17208        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17209        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17210        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17211        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17212        NodeFailureCode::LastWorkspace => "last-workspace",
17213        NodeFailureCode::NotGitRepository => "not-git-repository",
17214        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17215        NodeFailureCode::WorktreeProtected => "worktree-protected",
17216        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17217        NodeFailureCode::WorktreeLocked => "worktree-locked",
17218        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17219        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17220        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17221        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17222            "managed-worktree-profile-revision-mismatch"
17223        }
17224        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17225        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17226            "standalone-workspace-recovery-required"
17227        }
17228        NodeFailureCode::UnknownSession => "unknown-session",
17229        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17230        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17231        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17232        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17233        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17234        NodeFailureCode::WorkspaceRegistrationRequired => {
17235            "workspace-registration-required"
17236        }
17237        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17238        NodeFailureCode::StaleGeneration => "stale-generation",
17239        NodeFailureCode::BackendBusy => "backend-busy",
17240        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17241        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17242        NodeFailureCode::ShuttingDown => "shutting-down",
17243    }
17244}
17245
17246fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17247    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17248}
17249
17250fn durable_state_load_error(error: io::Error) -> NodeServerError {
17251    let category = match session_registry::state_load_refusal(&error) {
17252        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17253            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17254        }
17255        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17256            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17257        }
17258        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17259            DURABLE_STATE_CONFLICT_ERROR
17260        }
17261        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17262    };
17263    durable_state_server_error(error, category)
17264}
17265
17266fn unix_time_ms() -> u64 {
17267    std::time::SystemTime::now()
17268        .duration_since(std::time::UNIX_EPOCH)
17269        .unwrap_or_default()
17270        .as_millis()
17271        .min(u64::MAX as u128) as u64
17272}
17273
17274fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17275    if record.provider_session.is_some() {
17276        ManagedSessionState::Dormant
17277    } else {
17278        ManagedSessionState::Unavailable
17279    }
17280}
17281
17282fn active_record_state(
17283    identity_admitted: bool,
17284    provider_session_is_known: bool,
17285) -> ManagedSessionState {
17286    if identity_admitted && !provider_session_is_known {
17287        ManagedSessionState::IdentityPending
17288    } else {
17289        ManagedSessionState::Live
17290    }
17291}
17292
17293fn managed_worktree_record_holders(
17294    records: &[ManagedSessionRecord],
17295) -> Vec<(SessionRecordId, WorkspaceId)> {
17296    records
17297        .iter()
17298        .filter(|record| record.provider_session.is_some())
17299        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17300        .collect()
17301}
17302
17303fn managed_spawn_record_ownership(
17304    binding: &SessionBinding,
17305    effective_runtime_policy: ProviderRuntimePolicy,
17306) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17307    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17308    if effective_runtime_policy.provider_session_identity {
17309        (binding.record_id.clone(), None)
17310    } else {
17311        (None, binding.record_id.clone())
17312    }
17313}
17314
17315#[derive(Debug, Error)]
17316pub enum NodeServerError {
17317    #[error("node endpoint must be a bounded absolute local transport path")]
17318    InvalidEndpoint,
17319    #[error("node access token must contain 1..=4096 bytes")]
17320    InvalidAccessToken,
17321    #[error("node HTTP observer must listen on a loopback address: {0}")]
17322    InvalidApiListen(std::net::SocketAddr),
17323    #[error("node browser bridge must listen on a loopback address: {0}")]
17324    InvalidBridgeListen(std::net::SocketAddr),
17325    #[error("node browser bridge token must contain 1..=4096 bytes")]
17326    InvalidBridgeToken,
17327    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17328    BridgeUnderlayRequiresBridgeListen,
17329    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17330    BridgeUnderlayRequiresBridgeToken,
17331    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17332    InvalidBridgeUnderlayToken,
17333    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17334    InvalidBridgeUnderlayKey,
17335    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17336    InvalidCallHome(std::net::SocketAddr),
17337    #[error("node could not reach its call-home relay: {0}")]
17338    CallHomeTransport(#[source] std::io::Error),
17339    #[error("node durable state path must be an absolute file path: {0}")]
17340    InvalidStatePath(String),
17341    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17342    InvalidHarnessMcpHelper,
17343    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17344    InvalidNetworkAllowlistCatalogPath(String),
17345    #[error("network allowlist catalog could not be read: {0}")]
17346    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17347    #[error("network allowlist catalog line {line}: {message}")]
17348    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17349    #[error("network allowlist catalog: {0}")]
17350    InvalidNetworkAllowlistCatalog(String),
17351    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17352    NetworkAllowlistCatalogCapacity { max: usize },
17353    #[error("the local state directory is unavailable; supply an explicit state path")]
17354    LocalStateDirectoryUnavailable,
17355    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17356    LocalRuntimeDirectoryUnavailable,
17357    #[error("node durable state failed: {0}")]
17358    DurableState(io::Error),
17359    #[error("node requires at least one configured workspace")]
17360    NoWorkspaces,
17361    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17362    InvalidWorkspaceRoot {
17363        workspace_id: WorkspaceId,
17364        path: String,
17365        message: String,
17366    },
17367    #[error("duplicate workspace ID: {0}")]
17368    DuplicateWorkspaceId(WorkspaceId),
17369    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17370    DuplicateWorkspaceRoot {
17371        first: WorkspaceId,
17372        second: WorkspaceId,
17373        root: String,
17374    },
17375    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17376    ManagedWorktreeProfileRequired(WorkspaceId),
17377    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17378    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17379    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17380    DuplicateManagedWorktreeProfile {
17381        workspace_id: WorkspaceId,
17382        profile_id: WorktreeProfileId,
17383    },
17384    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17385    ManagedWorktreeProfileCapacity {
17386        workspace_id: WorkspaceId,
17387        max: usize,
17388    },
17389    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17390    InvalidManagedWorktreeProfile {
17391        workspace_id: WorkspaceId,
17392        profile_id: WorktreeProfileId,
17393        message: String,
17394    },
17395    #[error("duplicate node environment profile: {0}")]
17396    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17397    #[error("node environment profile provider is unavailable: {0}")]
17398    EnvironmentProfileProviderUnavailable(AgentId),
17399    #[error("node environment profile capacity is {max}")]
17400    EnvironmentProfileCapacity { max: usize },
17401    #[error("node environment profiles reuse native profile ID '{0}'")]
17402    DuplicateNativeEnvironmentProfile(String),
17403    #[error("node native environment profile is invalid: {0}")]
17404    NativeEnvironmentProfile(String),
17405    #[error("node session-environment materialization root must be absolute")]
17406    InvalidSessionEnvironmentRoot,
17407    #[error("node session-environment materializer is unavailable")]
17408    SessionEnvironmentMaterializerRequired,
17409    #[error("node session-environment materializer failed to initialize")]
17410    SessionEnvironmentMaterializer,
17411    #[error("node bundle catalog is invalid: {0}")]
17412    BundleCatalog(String),
17413    #[error("node delivery store failed to initialize: {0}")]
17414    DeliveryStore(String),
17415    #[error("node context pack store failed to initialize")]
17416    ContextPackStore,
17417    #[error("active agent registry failed: {0}")]
17418    Registry(String),
17419    #[error("node provider contract manifest is invalid: {0}")]
17420    ProviderContractManifest(String),
17421    #[error("named pipe I/O failed: {0}")]
17422    Io(#[from] io::Error),
17423    #[error("node HTTP observer failed: {0}")]
17424    HttpApi(io::Error),
17425    #[error("node browser bridge failed: {0}")]
17426    BridgeApi(io::Error),
17427    #[error(transparent)]
17428    Frame(#[from] FrameError),
17429    #[error("node handshake failed: {0}")]
17430    Handshake(String),
17431    #[error("node authentication frame was not received before the bounded deadline")]
17432    AuthenticationTimedOut,
17433    #[error("node authentication primitive failed: {0}")]
17434    Authentication(String),
17435    #[error("node incarnation identity generation failed: {0}")]
17436    IncarnationIdentity(String),
17437    #[error("node pre-authentication limiter is closed")]
17438    PreauthClosed,
17439    #[error("node authenticated connection limit was reached")]
17440    AuthenticatedConnectionLimit,
17441    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17442    SignalStreamClosed,
17443    #[error("node stop-all dispatch failed: {0:?}")]
17444    ShutdownDispatch(NodeFailure),
17445    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17446    ShutdownTimedOut { active_native_sessions: usize },
17447}
17448
17449impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17450    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17451        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17452        match error {
17453            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17454            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17455            E::InvalidEntry { line, message } => {
17456                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17457            }
17458            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17459            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17460        }
17461    }
17462}
17463
17464#[cfg(test)]
17465mod agent_stream_blocked_tests {
17466    use super::*;
17467    use gate4agent_types::{
17468        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17469        ProviderSource,
17470    };
17471
17472    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17473        ProviderSource {
17474            family,
17475            binding: AdapterBinding::new(
17476                AdapterId::new(adapter).unwrap(),
17477                "fixture/v1",
17478                AdapterVerification::SyntheticFixture,
17479            )
17480            .unwrap(),
17481        }
17482    }
17483
17484    fn provider_control_event_at(
17485        family: AdapterFamily,
17486        adapter: &str,
17487        source_sequence: u64,
17488        provider_event: ProviderEvent,
17489    ) -> ControlEvent {
17490        ControlEvent {
17491            sequence: 41,
17492            command_id: None,
17493            instance_id: AgentInstanceId(7),
17494            generation: SessionGeneration(3),
17495            event: ControlEventKind::ProviderEvent {
17496                sequence: 8,
17497                source: provider_source(family, adapter),
17498                source_sequence,
17499                event: provider_event,
17500            },
17501        }
17502    }
17503
17504    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17505        provider_control_event_at(family, "fixture", 9, provider_event)
17506    }
17507
17508    fn denied_host_request(
17509        by: ProviderHostDecisionAuthority,
17510        reason: Option<String>,
17511    ) -> ControlEvent {
17512        provider_control_event_at(
17513            AdapterFamily::Acp,
17514            "claude-code",
17515            2,
17516            ProviderEvent::HostRequestObserved {
17517                method: "terminal/create".to_owned(),
17518                params_json: String::new(),
17519                decision: ProviderHostRequestDecision::Denied { by },
17520                outcome: ProviderHostRequestOutcome::Executed,
17521                reason,
17522            },
17523        )
17524    }
17525
17526    fn blocked(
17527        event: &ControlEvent,
17528    ) -> (
17529        Option<String>,
17530        String,
17531        BlockAuthorityV1,
17532        Option<String>,
17533        String,
17534        Option<String>,
17535    ) {
17536        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17537        chunk.validate().expect("minted chunk must validate");
17538        let AgentStreamChunkKindV1::Blocked {
17539            correlation_id,
17540            tool_class,
17541            authority,
17542            reason_kind,
17543            reason,
17544            help,
17545        } = chunk.kind
17546        else {
17547            panic!("expected AgentStreamChunkKindV1::Blocked");
17548        };
17549        (correlation_id, tool_class, authority, reason_kind, reason, help)
17550    }
17551
17552    /// Pins the fleet and every adapter each member declares: the fleet is
17553    /// exactly what `active_registry` says, and no member declares a hook
17554    /// adapter (lifecycle hooks are retired).
17555    #[test]
17556    fn the_enabled_provider_matrix_is_exact() {
17557        let registry = active_registry().unwrap();
17558        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17559        assert_eq!(
17560            providers
17561                .iter()
17562                .map(|contract| contract.provider.as_str())
17563                .collect::<Vec<_>>(),
17564            vec!["claude", "codex", "grok", "kimi"],
17565        );
17566        assert!(adapters.iter().all(|contract| {
17567            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17568        }));
17569    }
17570
17571    /// A gate-denied host request, WITH its reason text carried all the way
17572    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17573    #[test]
17574    fn gate_denied_host_request_mints_a_blocked_chunk() {
17575        let gate_text =
17576            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17577        let event = denied_host_request(
17578            ProviderHostDecisionAuthority::Gate,
17579            Some(gate_text.to_owned()),
17580        );
17581        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17582        assert_eq!(chunk.source_sequence, 2);
17583        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17584        assert_eq!(authority, BlockAuthorityV1::HostGate);
17585        assert_eq!(reason, gate_text);
17586        assert_eq!(correlation_id, None);
17587        assert_eq!(reason_kind, None);
17588        assert_eq!(help, None);
17589        assert_eq!(tool_class, "Write");
17590    }
17591
17592    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.";
17593
17594    /// The owner's classifier sample, verbatim, arriving as a
17595    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17596    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17597    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17598    /// and the guidance tail as `help`.
17599    #[test]
17600    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17601        let event = provider_control_event(
17602            AdapterFamily::Acp,
17603            ProviderEvent::ToolCompleted {
17604                id: "toolu_9".to_owned(),
17605                output: CLASSIFIER_SAMPLE.to_owned(),
17606                is_error: true,
17607                duration_ms: None,
17608                agent_id: None,
17609                non_execution_kind: Some("permission-rule".to_owned()),
17610            },
17611        );
17612        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17613        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17614        assert_eq!(reason, "Blocked by classifier");
17615        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17616        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17617        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17618    }
17619
17620    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17621    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17622    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17623    /// vendor code as `reason_kind`.
17624    #[test]
17625    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17626        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.";
17627        let event = provider_control_event(
17628            AdapterFamily::Acp,
17629            ProviderEvent::SessionEnded {
17630                result: "JSON-RPC error -32603: Internal error".to_owned(),
17631                cost_usd: None,
17632                is_error: true,
17633                stop_reason: Some(ProviderStopReason::ProviderError {
17634                    code: -32603,
17635                    message: message.to_owned(),
17636                    vendor_code: Some("usageLimitExceeded".to_owned()),
17637                }),
17638            },
17639        );
17640        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17641        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17642        assert_eq!(reason, message);
17643        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17644        assert_eq!(tool_class, "Session");
17645    }
17646
17647    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17648    /// `authority: ProviderRefusal`.
17649    #[test]
17650    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17651        let event = provider_control_event(
17652            AdapterFamily::Acp,
17653            ProviderEvent::SessionEnded {
17654                result: "refusal".to_owned(),
17655                cost_usd: None,
17656                is_error: true,
17657                stop_reason: Some(ProviderStopReason::Refusal),
17658            },
17659        );
17660        let (_, _, authority, _, _, _) = blocked(&event);
17661        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17662    }
17663
17664    /// A failed tool completion with no typed `non_execution_kind` -- or one
17665    /// that only says the turn stopped -- is a plain execution failure, never a
17666    /// block; nor is an agent's own narrative claiming one.
17667    #[test]
17668    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17669        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17670            let event = provider_control_event(
17671                AdapterFamily::Acp,
17672                ProviderEvent::ToolCompleted {
17673                    id: "toolu_1".to_owned(),
17674                    output: "command exited with status 1".to_owned(),
17675                    is_error: true,
17676                    duration_ms: Some(12),
17677                    agent_id: None,
17678                    non_execution_kind,
17679                },
17680            );
17681            assert!(agent_stream_chunk(&event).is_none());
17682        }
17683        let narrative = provider_control_event(
17684            AdapterFamily::Acp,
17685            ProviderEvent::ToolCompleted {
17686                id: "toolu_2".to_owned(),
17687                output: "the harness blocked the write, approval needed".to_owned(),
17688                is_error: false,
17689                duration_ms: Some(3),
17690                agent_id: None,
17691                non_execution_kind: None,
17692            },
17693        );
17694        assert!(agent_stream_chunk(&narrative).is_none());
17695    }
17696
17697    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17698    /// execution outcome: a granted request that failed while running was
17699    /// still authorized.
17700    #[test]
17701    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17702        for (decision, outcome) in [
17703            (
17704                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17705                ProviderHostRequestOutcome::Executed,
17706            ),
17707            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17708            (
17709                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17710                ProviderHostRequestOutcome::Failed {
17711                    error: "terminal/create spawn failed: os error 3".to_owned(),
17712                },
17713            ),
17714        ] {
17715            let event = provider_control_event_at(
17716                AdapterFamily::Acp,
17717                "claude-code",
17718                3,
17719                ProviderEvent::HostRequestObserved {
17720                    method: "fs/read_text_file".to_owned(),
17721                    params_json: String::new(),
17722                    decision: decision.clone(),
17723                    outcome,
17724                    reason: None,
17725                },
17726            );
17727            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17728        }
17729    }
17730
17731    /// A deferred `session/request_permission` resolved by an operator or an
17732    /// expired deadline carries no reason text -- the chunk falls back to a
17733    /// categorical sentence naming the authority rather than inventing detail.
17734    #[test]
17735    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17736        for (by, expected_authority, expected_reason) in [
17737            (
17738                ProviderHostDecisionAuthority::Operator,
17739                BlockAuthorityV1::Operator,
17740                "declined by the operator",
17741            ),
17742            (
17743                ProviderHostDecisionAuthority::DeadlinePolicy,
17744                BlockAuthorityV1::HostDeadline,
17745                "denied by host policy after the operator deadline expired",
17746            ),
17747        ] {
17748            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17749            assert_eq!(authority, expected_authority);
17750            assert_eq!(reason, expected_reason);
17751        }
17752    }
17753
17754    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17755    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17756    /// outright rather than truncating.
17757    #[test]
17758    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17759        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17760        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17761            ProviderHostDecisionAuthority::Policy,
17762            Some(oversized),
17763        ));
17764        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17765    }
17766}
17767
17768#[cfg(test)]
17769mod standalone_capability_platform_tests {
17770    use super::*;
17771
17772    #[test]
17773    fn standalone_workspace_capability_requires_windows_identity_guards() {
17774        let capabilities = baseline_capabilities().unwrap();
17775        let advertised = capabilities.iter().any(|capability| {
17776            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17777        });
17778        assert_eq!(advertised, cfg!(windows));
17779
17780        let request = NodeRequest::CreateStandaloneWorkspace {
17781            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17782            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17783            initial_branch: None,
17784        };
17785        assert_eq!(
17786            request_uses_unnegotiated_capability(&request, &capabilities),
17787            !cfg!(windows),
17788        );
17789    }
17790}
17791
17792
17793#[cfg(test)]
17794mod station_knobs_resolve_tests {
17795    use super::*;
17796    use gate4agent_catalog::EnvMutation;
17797    use gate4agent_runtime_native::{
17798        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17799        NativeLaunchProfileId,
17800    };
17801    use std::ffi::OsString;
17802    use std::sync::Arc;
17803
17804    struct EmptyEnvironmentResolver;
17805
17806    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17807        fn resolve_child_environment(
17808            &self,
17809        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17810            Ok(vec![EnvMutation {
17811                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17812                value: None,
17813            }])
17814        }
17815    }
17816
17817    fn agent(value: &str) -> AgentId {
17818        AgentId::new(value).unwrap()
17819    }
17820
17821    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17822        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17823        let profile_revision =
17824            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17825        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17826            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17827            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17828            provider: agent("claude"),
17829            mode: SessionMode::Pty,
17830            terminal_size: gate4agent_types::TerminalSize {
17831                rows: 24,
17832                columns: 80,
17833            },
17834            prompt: None,
17835            bundle_id: None,
17836            context_id: None,
17837            environment_profile_id: Some(profile_id.clone()),
17838        };
17839        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17840        let catalog = active_registry().unwrap();
17841        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17842        let control = runtime.native_launch_profile_control();
17843        let node_profile = NodeEnvironmentProfile::new(
17844            profile_id.clone(),
17845            profile_revision,
17846            agent("claude"),
17847            [NativeLaunchProfile::new(
17848                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17849                agent("claude"),
17850                TransportKind::Pty,
17851                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17852                Arc::new(EmptyEnvironmentResolver),
17853            )
17854            .unwrap()],
17855        )
17856        .unwrap();
17857        let (binding, native_profiles, _materialization) = node_profile.into_parts();
17858        for native_profile in native_profiles {
17859            runtime.upsert_native_launch_profile(native_profile).unwrap();
17860        }
17861        let workspace = WorkspaceConfig::new(
17862            WorkspaceId::new("primary").unwrap(),
17863            std::env::current_dir().unwrap(),
17864        )
17865        .unwrap();
17866        let shared = NodeShared::new_with_incarnation(
17867            handle,
17868            "fixture-token".to_owned(),
17869            NodeId::new("node-station-knobs-test").unwrap(),
17870            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17871            vec![workspace],
17872            vec![agent("claude")],
17873            ProviderRuntimeStatuses::default(),
17874            None,
17875            Vec::new(),
17876            Vec::new(),
17877            spawn_profiles,
17878            Some(control),
17879            None,
17880            None,
17881            None,
17882            Vec::new(),
17883            Vec::new(),
17884            Vec::new(),
17885            Vec::new(),
17886            None,
17887            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17888        );
17889        shared
17890            .environment_profiles
17891            .write()
17892            .unwrap_or_else(|poisoned| poisoned.into_inner())
17893            .insert(binding.id.clone(), binding);
17894        (shared, spawn_defaults)
17895    }
17896
17897    /// Empty-default catalog refuses unknown network_allowlist ids.
17898    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
17899    #[test]
17900    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17901        let (shared, defaults) = station_knobs_fixture();
17902        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17903        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17904        let spec = crate::protocol::SpawnSpec {
17905            target: crate::protocol::SpawnTarget {
17906                node_id: shared.node_id.clone(),
17907                workspace_id: WorkspaceId::new("primary").unwrap(),
17908                worktree_id: None,
17909            },
17910            profile_id: defaults.profile_id.clone(),
17911            expected_profile_revision: defaults.revision.clone(),
17912            overrides: crate::protocol::SpawnOverrides {
17913                network_allowlist: Some(unknown),
17914                browser_profile_id: Some(browser),
17915                ..crate::protocol::SpawnOverrides::default()
17916            },
17917            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17918            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17919                "station-knobs-unknown-allowlist",
17920            )
17921            .unwrap(),
17922            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17923        };
17924        let resolved = spec.resolve(&defaults).unwrap();
17925        let failure = shared
17926            .resolve_environment_profile(&resolved)
17927            .expect_err("unknown allowlist must refuse");
17928        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17929    }
17930
17931    /// Registered network_allowlist (+ browser_profile_id when feature off)
17932    /// echo onto the env-profile receipt. Empty catalog still allows omit
17933    /// (None). Empty/whitespace refuse is type-level. Feature-off
17934    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
17935    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
17936    #[test]
17937    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17938        let (shared, defaults) = station_knobs_fixture();
17939        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17940        #[cfg(not(feature = "dig2-station-probe"))]
17941        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17942        shared
17943            .network_allowlist_catalog
17944            .write()
17945            .unwrap_or_else(|poisoned| poisoned.into_inner())
17946            .insert_id(allowlist.clone())
17947            .unwrap();
17948        let spec = crate::protocol::SpawnSpec {
17949            target: crate::protocol::SpawnTarget {
17950                node_id: shared.node_id.clone(),
17951                workspace_id: WorkspaceId::new("primary").unwrap(),
17952                worktree_id: None,
17953            },
17954            profile_id: defaults.profile_id.clone(),
17955            expected_profile_revision: defaults.revision.clone(),
17956            overrides: crate::protocol::SpawnOverrides {
17957                network_allowlist: Some(allowlist.clone()),
17958                #[cfg(not(feature = "dig2-station-probe"))]
17959                browser_profile_id: Some(browser.clone()),
17960                ..crate::protocol::SpawnOverrides::default()
17961            },
17962            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17963            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17964                .unwrap(),
17965            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17966        };
17967        let resolved = spec.resolve(&defaults).unwrap();
17968        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17969        #[cfg(not(feature = "dig2-station-probe"))]
17970        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17971        let receipt = shared
17972            .resolve_environment_profile(&resolved)
17973            .expect("resolve ok")
17974            .expect("env profile present");
17975        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17976        #[cfg(not(feature = "dig2-station-probe"))]
17977        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17978        #[cfg(feature = "dig2-station-probe")]
17979        assert_eq!(receipt.browser_profile_id, None);
17980        assert_eq!(receipt.profile_id.as_str(), "local-claude");
17981
17982        let omitted = crate::protocol::SpawnSpec {
17983            overrides: crate::protocol::SpawnOverrides::default(),
17984            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17985                .unwrap(),
17986            ..spec
17987        };
17988        let resolved_omit = omitted.resolve(&defaults).unwrap();
17989        let bare = shared
17990            .resolve_environment_profile(&resolved_omit)
17991            .expect("resolve ok")
17992            .expect("env profile present");
17993        assert_eq!(bare.network_allowlist, None);
17994        assert_eq!(bare.browser_profile_id, None);
17995    }
17996
17997    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
17998    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
17999    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18000    /// unit-tested separately (probe refuses before lease on Linux).
18001    #[cfg(feature = "dig2-station-probe")]
18002    #[test]
18003    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18004        let (shared, defaults) = station_knobs_fixture();
18005        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18006        let spec = crate::protocol::SpawnSpec {
18007            target: crate::protocol::SpawnTarget {
18008                node_id: shared.node_id.clone(),
18009                workspace_id: WorkspaceId::new("primary").unwrap(),
18010                worktree_id: None,
18011            },
18012            profile_id: defaults.profile_id.clone(),
18013            expected_profile_revision: defaults.revision.clone(),
18014            overrides: crate::protocol::SpawnOverrides {
18015                browser_profile_id: Some(browser),
18016                ..crate::protocol::SpawnOverrides::default()
18017            },
18018            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18019            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18020                "station-knobs-probe-refuse",
18021            )
18022            .unwrap(),
18023            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18024        };
18025        let resolved = spec.resolve(&defaults).unwrap();
18026        let failure = shared
18027            .resolve_environment_profile(&resolved)
18028            .expect_err("browser_profile with failing probe must refuse");
18029        #[cfg(windows)]
18030        {
18031            assert!(
18032                matches!(
18033                    failure.code,
18034                    NodeFailureCode::BrowserStationUnreachable
18035                        | NodeFailureCode::BrowserStationProbeUnavailable
18036                ),
18037                "unexpected code {:?}",
18038                failure.code
18039            );
18040        }
18041        #[cfg(not(windows))]
18042        {
18043            assert_eq!(
18044                failure.code,
18045                NodeFailureCode::BrowserStationProbeUnavailable
18046            );
18047        }
18048    }
18049
18050    /// Feature on: omitting browser_profile_id still resolves (no probe).
18051    #[cfg(feature = "dig2-station-probe")]
18052    #[test]
18053    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18054        let (shared, defaults) = station_knobs_fixture();
18055        let spec = crate::protocol::SpawnSpec {
18056            target: crate::protocol::SpawnTarget {
18057                node_id: shared.node_id.clone(),
18058                workspace_id: WorkspaceId::new("primary").unwrap(),
18059                worktree_id: None,
18060            },
18061            profile_id: defaults.profile_id.clone(),
18062            expected_profile_revision: defaults.revision.clone(),
18063            overrides: crate::protocol::SpawnOverrides::default(),
18064            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18065            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18066                "station-knobs-probe-omit",
18067            )
18068            .unwrap(),
18069            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18070        };
18071        let resolved = spec.resolve(&defaults).unwrap();
18072        let receipt = shared
18073            .resolve_environment_profile(&resolved)
18074            .expect("omit browser_profile must not probe")
18075            .expect("env profile present");
18076        assert_eq!(receipt.browser_profile_id, None);
18077    }
18078
18079    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18080    /// refuses a second holder for the same opaque profile id; release on
18081    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18082    #[cfg(feature = "dig2-station-probe")]
18083    #[test]
18084    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18085        let (shared, _defaults) = station_knobs_fixture();
18086        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18087        let workspace_id = WorkspaceId::new("primary").unwrap();
18088        let address_a = SessionAddress {
18089            workspace_id: workspace_id.clone(),
18090            session: SessionKey {
18091                instance_id: AgentInstanceId(101),
18092                generation: SessionGeneration(1),
18093            },
18094        };
18095        let address_b = SessionAddress {
18096            workspace_id: workspace_id.clone(),
18097            session: SessionKey {
18098                instance_id: AgentInstanceId(102),
18099                generation: SessionGeneration(1),
18100            },
18101        };
18102        let guard_a = crate::dig2_station_lease::acquire_lease(
18103            &shared.browser_station_leases,
18104            profile.clone(),
18105            address_a.session.instance_id,
18106            1,
18107        )
18108        .expect("first lease");
18109        let busy = crate::dig2_station_lease::acquire_lease(
18110            &shared.browser_station_leases,
18111            profile.clone(),
18112            address_b.session.instance_id,
18113            2,
18114        )
18115        .expect_err("second holder must refuse");
18116        assert!(matches!(
18117            busy,
18118            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18119        ));
18120        // Simulate a bound session so remove_binding clears the lease.
18121        shared.bind_session_with_policy(
18122            &address_a,
18123            ProviderRuntimePolicy::raw_pty(),
18124            None,
18125        );
18126        guard_a.retain();
18127        assert!(shared.remove_binding(&address_a).is_some());
18128        crate::dig2_station_lease::acquire_lease(
18129            &shared.browser_station_leases,
18130            profile,
18131            address_b.session.instance_id,
18132            3,
18133        )
18134        .expect("reacquire after clear")
18135        .retain();
18136        assert!(
18137            crate::dig2_station_lease::release_lease_for_holder(
18138                &shared.browser_station_leases,
18139                address_b.session.instance_id,
18140            )
18141        );
18142    }
18143
18144    /// Feature-off: no browser_station_leases field; stub echo documented in
18145    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18146    #[cfg(not(feature = "dig2-station-probe"))]
18147    #[test]
18148    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18149        let (shared, defaults) = station_knobs_fixture();
18150        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18151        let spec = crate::protocol::SpawnSpec {
18152            target: crate::protocol::SpawnTarget {
18153                node_id: shared.node_id.clone(),
18154                workspace_id: WorkspaceId::new("primary").unwrap(),
18155                worktree_id: None,
18156            },
18157            profile_id: defaults.profile_id.clone(),
18158            expected_profile_revision: defaults.revision.clone(),
18159            overrides: crate::protocol::SpawnOverrides {
18160                browser_profile_id: Some(browser.clone()),
18161                ..crate::protocol::SpawnOverrides::default()
18162            },
18163            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18164            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18165                .unwrap(),
18166            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18167        };
18168        let resolved = spec.resolve(&defaults).unwrap();
18169        let receipt = shared
18170            .resolve_environment_profile(&resolved)
18171            .expect("stub resolve")
18172            .expect("env profile");
18173        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18174    }
18175
18176
18177    /// Startup file loader registers ids into the empty-default catalog.
18178    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18179    #[test]
18180    fn network_allowlist_catalog_loads_from_temp_file() {
18181        let unique = std::time::SystemTime::now()
18182            .duration_since(std::time::UNIX_EPOCH)
18183            .unwrap()
18184            .as_nanos();
18185        let path = std::env::temp_dir().join(format!(
18186            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18187            std::process::id(),
18188        ));
18189        std::fs::write(
18190            &path,
18191            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18192        )
18193        .unwrap();
18194        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18195            .expect("temp catalog file must load");
18196        let _ = std::fs::remove_file(&path);
18197        assert_eq!(loaded.len(), 2);
18198        let (shared, defaults) = station_knobs_fixture();
18199        {
18200            let mut catalog = shared
18201                .network_allowlist_catalog
18202                .write()
18203                .unwrap_or_else(|poisoned| poisoned.into_inner());
18204            *catalog = loaded;
18205        }
18206        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18207        let spec = crate::protocol::SpawnSpec {
18208            target: crate::protocol::SpawnTarget {
18209                node_id: shared.node_id.clone(),
18210                workspace_id: WorkspaceId::new("primary").unwrap(),
18211                worktree_id: None,
18212            },
18213            profile_id: defaults.profile_id.clone(),
18214            expected_profile_revision: defaults.revision.clone(),
18215            overrides: crate::protocol::SpawnOverrides {
18216                network_allowlist: Some(allowlist.clone()),
18217                ..crate::protocol::SpawnOverrides::default()
18218            },
18219            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18220            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18221                "station-knobs-catalog-file",
18222            )
18223            .unwrap(),
18224            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18225        };
18226        let resolved = spec.resolve(&defaults).unwrap();
18227        let receipt = shared
18228            .resolve_environment_profile(&resolved)
18229            .expect("registered via file load")
18230            .expect("env profile present");
18231        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18232    }
18233
18234    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18235    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18236    #[test]
18237    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18238        let (shared, _defaults) = station_knobs_fixture();
18239        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18240        shared
18241            .network_allowlist_catalog
18242            .write()
18243            .unwrap_or_else(|poisoned| poisoned.into_inner())
18244            .insert_id(allowlist.clone())
18245            .unwrap();
18246        let snapshot = shared.snapshot();
18247        let inventory = snapshot
18248            .launch_inventory
18249            .expect("launch inventory present");
18250        let ids = inventory
18251            .network_allowlists
18252            .expect("non-empty catalog surfaces ids");
18253        assert_eq!(ids, vec![allowlist]);
18254    }
18255
18256    /// Track B: registered id with provider_native codex_network_access refuses
18257    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18258    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18259    #[test]
18260    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18261        let (shared, defaults) = station_knobs_fixture();
18262        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18263        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18264            id: allowlist.clone(),
18265            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18266                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18267                peer: "127.0.0.1:443".to_owned(),
18268            }],
18269            provider_native: Some(
18270                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18271                    codex_network_access: Some(true),
18272                },
18273            ),
18274        };
18275        shared
18276            .network_allowlist_catalog
18277            .write()
18278            .unwrap_or_else(|poisoned| poisoned.into_inner())
18279            .insert(entry)
18280            .unwrap();
18281        let spec = crate::protocol::SpawnSpec {
18282            target: crate::protocol::SpawnTarget {
18283                node_id: shared.node_id.clone(),
18284                workspace_id: WorkspaceId::new("primary").unwrap(),
18285                worktree_id: None,
18286            },
18287            profile_id: defaults.profile_id.clone(),
18288            expected_profile_revision: defaults.revision.clone(),
18289            overrides: crate::protocol::SpawnOverrides {
18290                network_allowlist: Some(allowlist),
18291                ..crate::protocol::SpawnOverrides::default()
18292            },
18293            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18294            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18295                "station-knobs-unsupported-mapping",
18296            )
18297            .unwrap(),
18298            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18299        };
18300        let resolved = spec.resolve(&defaults).unwrap();
18301        assert_eq!(resolved.provider.as_str(), "claude");
18302        let failure = shared
18303            .resolve_environment_profile(&resolved)
18304            .expect_err("claude + codex_network_access must refuse");
18305        assert_eq!(
18306            failure.code,
18307            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18308        );
18309    }
18310
18311    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18312    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18313    /// Never secrets on C2.
18314    #[test]
18315    fn resolve_codex_network_access_overlay_moderate_only() {
18316        let (shared, defaults) = station_knobs_fixture();
18317        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18318        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18319            id: allowlist.clone(),
18320            permits: Vec::new(),
18321            provider_native: Some(
18322                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18323                    codex_network_access: Some(true),
18324                },
18325            ),
18326        };
18327        shared
18328            .network_allowlist_catalog
18329            .write()
18330            .unwrap_or_else(|poisoned| poisoned.into_inner())
18331            .insert(entry)
18332            .unwrap();
18333
18334        let overlay = shared
18335            .resolve_codex_network_access_overlay_args(
18336                &agent("codex"),
18337                SessionMode::Pty,
18338                ApprovalLevel::Moderate,
18339                Some(&allowlist),
18340            )
18341            .expect("Moderate Codex maps network_access");
18342        assert_eq!(
18343            overlay,
18344            vec![
18345                std::ffi::OsString::from("-c"),
18346                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18347            ]
18348        );
18349
18350        let acp = shared
18351            .resolve_codex_network_access_overlay_args(
18352                &agent("codex"),
18353                SessionMode::Acp,
18354                ApprovalLevel::Moderate,
18355                Some(&allowlist),
18356            )
18357            .unwrap();
18358        assert_eq!(acp, overlay);
18359
18360        let inline = shared
18361            .resolve_codex_network_access_overlay_args(
18362                &agent("codex"),
18363                SessionMode::Inline,
18364                ApprovalLevel::Moderate,
18365                Some(&allowlist),
18366            )
18367            .unwrap_err();
18368        assert_eq!(
18369            inline.code,
18370            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18371        );
18372
18373        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18374            let spec = crate::protocol::SpawnSpec {
18375                target: crate::protocol::SpawnTarget {
18376                    node_id: shared.node_id.clone(),
18377                    workspace_id: WorkspaceId::new("primary").unwrap(),
18378                    worktree_id: None,
18379                },
18380                profile_id: defaults.profile_id.clone(),
18381                expected_profile_revision: defaults.revision.clone(),
18382                overrides: crate::protocol::SpawnOverrides {
18383                    provider: crate::protocol::SpawnOverride::Set {
18384                        value: agent("codex"),
18385                    },
18386                    approval_level: Some(level),
18387                    network_allowlist: Some(allowlist.clone()),
18388                    ..crate::protocol::SpawnOverrides::default()
18389                },
18390                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18391                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18392                    "codex-net-refuse-{}",
18393                    match level {
18394                        ApprovalLevel::ReadOnly => "ro",
18395                        ApprovalLevel::FullAuto => "fa",
18396                        _ => "other",
18397                    }
18398                ))
18399                .unwrap(),
18400                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18401            };
18402            let resolved = spec.resolve(&defaults).unwrap();
18403            let failure = shared
18404                .resolve_environment_profile(&resolved)
18405                .expect_err("RO/FullAuto must refuse");
18406            assert_eq!(
18407                failure.code,
18408                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18409            );
18410        }
18411    }
18412
18413    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18414    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18415    /// Never secrets on C2; does not invent provider_native Claude keys.
18416    #[test]
18417    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18418        let (shared, _defaults) = station_knobs_fixture();
18419        let allowlist =
18420            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18421        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18422            id: allowlist.clone(),
18423            permits: vec![
18424                crate::network_allowlist_catalog::NetworkPermitSketch {
18425                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18426                    peer: "github.com:443".to_owned(),
18427                },
18428                crate::network_allowlist_catalog::NetworkPermitSketch {
18429                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18430                    peer: "*.npmjs.org".to_owned(),
18431                },
18432            ],
18433            provider_native: None,
18434        };
18435        shared
18436            .network_allowlist_catalog
18437            .write()
18438            .unwrap_or_else(|poisoned| poisoned.into_inner())
18439            .insert(entry)
18440            .unwrap();
18441
18442        let unique = std::time::SystemTime::now()
18443            .duration_since(std::time::UNIX_EPOCH)
18444            .unwrap()
18445            .as_nanos();
18446        let provider_home = std::env::temp_dir().join(format!(
18447            "gate4agent-claude-provider-home-{}-{unique}",
18448            std::process::id(),
18449        ));
18450        std::fs::create_dir_all(&provider_home).unwrap();
18451
18452        let overlay = shared
18453            .resolve_network_allowlist_overlay_args(
18454                &agent("claude"),
18455                SessionMode::Pty,
18456                ApprovalLevel::Moderate,
18457                Some(&allowlist),
18458                Some(provider_home.as_path()),
18459            )
18460            .expect("Claude PTY settings overlay on unix");
18461        let settings_path = provider_home.join(
18462            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18463        );
18464        assert_eq!(
18465            overlay,
18466            vec![
18467                std::ffi::OsString::from("--settings"),
18468                std::ffi::OsString::from(settings_path.as_os_str()),
18469            ]
18470        );
18471        let body = std::fs::read_to_string(&settings_path).unwrap();
18472        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18473        assert_eq!(value["sandbox"]["enabled"], true);
18474        assert_eq!(
18475            value["sandbox"]["network"]["allowedDomains"],
18476            serde_json::json!(["github.com", "*.npmjs.org"])
18477        );
18478        assert!(settings_path.starts_with(&provider_home));
18479
18480        let acp = shared
18481            .resolve_network_allowlist_overlay_args(
18482                &agent("claude"),
18483                SessionMode::Acp,
18484                ApprovalLevel::Moderate,
18485                Some(&allowlist),
18486                Some(provider_home.as_path()),
18487            )
18488            .expect_err("Claude ACP must refuse settings overlay");
18489        assert_eq!(
18490            acp.code,
18491            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18492        );
18493
18494        let no_home = shared
18495            .resolve_network_allowlist_overlay_args(
18496                &agent("claude"),
18497                SessionMode::Pty,
18498                ApprovalLevel::Moderate,
18499                Some(&allowlist),
18500                None,
18501            )
18502            .expect_err("Claude overlay requires ProviderHome");
18503        assert_eq!(
18504            no_home.code,
18505            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18506        );
18507
18508        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18509        shared
18510            .network_allowlist_catalog
18511            .write()
18512            .unwrap_or_else(|poisoned| poisoned.into_inner())
18513            .insert_id(bare.clone())
18514            .unwrap();
18515        let empty = shared
18516            .resolve_network_allowlist_overlay_args(
18517                &agent("claude"),
18518                SessionMode::Pty,
18519                ApprovalLevel::Moderate,
18520                Some(&bare),
18521                Some(provider_home.as_path()),
18522            )
18523            .unwrap();
18524        assert!(empty.is_empty());
18525
18526        let _ = std::fs::remove_dir_all(&provider_home);
18527    }
18528
18529    /// Kimi / Grok with permits stay without invented argv overlays.
18530    #[test]
18531    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18532        let (shared, _defaults) = station_knobs_fixture();
18533        let allowlist =
18534            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18535        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18536            id: allowlist.clone(),
18537            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18538                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18539                peer: "example.com:443".to_owned(),
18540            }],
18541            provider_native: None,
18542        };
18543        shared
18544            .network_allowlist_catalog
18545            .write()
18546            .unwrap_or_else(|poisoned| poisoned.into_inner())
18547            .insert(entry)
18548            .unwrap();
18549        for provider in ["kimi", "grok"] {
18550            let overlay = shared
18551                .resolve_network_allowlist_overlay_args(
18552                    &agent(provider),
18553                    SessionMode::Pty,
18554                    ApprovalLevel::Moderate,
18555                    Some(&allowlist),
18556                    None,
18557                )
18558                .unwrap();
18559            assert!(
18560                overlay.is_empty(),
18561                "{provider} must not invent network argv from permits"
18562            );
18563        }
18564    }
18565
18566    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18567    #[test]
18568    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18569        let unique = std::time::SystemTime::now()
18570            .duration_since(std::time::UNIX_EPOCH)
18571            .unwrap()
18572            .as_nanos();
18573        let path = std::env::temp_dir().join(format!(
18574            "gate4agent-node-allowlist-json-{}-{unique}.json",
18575            std::process::id(),
18576        ));
18577        std::fs::write(
18578            &path,
18579            r#"{
18580              "schema_version": 2,
18581              "entries": [
18582                {
18583                  "id": "egress-default",
18584                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18585                },
18586                { "id": "lab-egress" }
18587              ]
18588            }"#,
18589        )
18590        .unwrap();
18591        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18592            .expect("json v2 catalog must load");
18593        let _ = std::fs::remove_file(&path);
18594        assert_eq!(loaded.len(), 2);
18595        let entry = loaded
18596            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18597            .unwrap();
18598        assert_eq!(entry.permits.len(), 1);
18599        let (shared, defaults) = station_knobs_fixture();
18600        {
18601            let mut catalog = shared
18602                .network_allowlist_catalog
18603                .write()
18604                .unwrap_or_else(|poisoned| poisoned.into_inner());
18605            *catalog = loaded;
18606        }
18607        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18608        let spec = crate::protocol::SpawnSpec {
18609            target: crate::protocol::SpawnTarget {
18610                node_id: shared.node_id.clone(),
18611                workspace_id: WorkspaceId::new("primary").unwrap(),
18612                worktree_id: None,
18613            },
18614            profile_id: defaults.profile_id.clone(),
18615            expected_profile_revision: defaults.revision.clone(),
18616            overrides: crate::protocol::SpawnOverrides {
18617                network_allowlist: Some(allowlist.clone()),
18618                ..crate::protocol::SpawnOverrides::default()
18619            },
18620            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18621            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18622                "station-knobs-catalog-json",
18623            )
18624            .unwrap(),
18625            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18626        };
18627        let resolved = spec.resolve(&defaults).unwrap();
18628        let receipt = shared
18629            .resolve_environment_profile(&resolved)
18630            .expect("permits-only entry ok for claude")
18631            .expect("env profile present");
18632        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18633        let inventory = shared
18634            .snapshot()
18635            .launch_inventory
18636            .expect("launch inventory present");
18637        let ids = inventory.network_allowlists.expect("ids surface");
18638        assert_eq!(
18639            ids,
18640            vec![
18641                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18642                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18643            ]
18644        );
18645    }
18646
18647
18648}
18649
18650
18651#[cfg(test)]
18652mod workspace_environment_collision_tests {
18653    use super::*;
18654    use crate::session_environment::{
18655        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18656    };
18657    use gate4agent_catalog::EnvMutation;
18658    use gate4agent_runtime_native::{
18659        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18660        NativeLaunchProfileId,
18661    };
18662    use std::ffi::OsString;
18663    use std::sync::Arc;
18664
18665    struct EmptyEnvironmentResolver;
18666
18667    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18668        fn resolve_child_environment(
18669            &self,
18670        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18671            Ok(vec![EnvMutation {
18672                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18673                value: None,
18674            }])
18675        }
18676    }
18677
18678    fn agent(value: &str) -> AgentId {
18679        AgentId::new(value).unwrap()
18680    }
18681
18682    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18683    fn claude_config_dir_collision_fixture() -> (
18684        NodeShared,
18685        ResolvedEnvironmentProfileReceipt,
18686        ResolvedEnvironmentProfileReceipt,
18687    ) {
18688        let catalog = active_registry().unwrap();
18689        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18690        let control = runtime.native_launch_profile_control();
18691        let workspace = WorkspaceConfig::new(
18692            WorkspaceId::new("primary").unwrap(),
18693            std::env::current_dir().unwrap(),
18694        )
18695        .unwrap();
18696        let shared = NodeShared::new_with_incarnation(
18697            handle,
18698            "fixture-token".to_owned(),
18699            NodeId::new("node-env-collision-test").unwrap(),
18700            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18701            vec![workspace],
18702            vec![agent("claude")],
18703            ProviderRuntimeStatuses::default(),
18704            None,
18705            Vec::new(),
18706            Vec::new(),
18707            SpawnProfileRegistry::default(),
18708            Some(control),
18709            None,
18710            None,
18711            None,
18712            Vec::new(),
18713            Vec::new(),
18714            Vec::new(),
18715            Vec::new(),
18716            None,
18717            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18718        );
18719
18720        let mut receipts = Vec::new();
18721        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18722            let profile_id =
18723                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18724            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18725                "claude-config-dir-{suffix}-r1"
18726            ))
18727            .unwrap();
18728            let materialization = NodeSessionMaterializationProfile::new(
18729                Vec::new(),
18730                vec![NodeSessionPathBinding::new(
18731                    "CLAUDE_CONFIG_DIR",
18732                    NodeSessionPathClass::ProviderHome,
18733                )
18734                .unwrap()],
18735                Vec::new(),
18736            )
18737            .unwrap();
18738            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18739                profile_id.clone(),
18740                profile_revision.clone(),
18741                agent("claude"),
18742                [NativeLaunchProfile::new(
18743                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18744                        .unwrap(),
18745                    agent("claude"),
18746                    TransportKind::Pty,
18747                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18748                    Arc::new(EmptyEnvironmentResolver),
18749                )
18750                .unwrap()],
18751                Some(materialization),
18752            )
18753            .unwrap();
18754            let (binding, native_profiles, materialization) = node_profile.into_parts();
18755            for native_profile in native_profiles {
18756                runtime.upsert_native_launch_profile(native_profile).unwrap();
18757            }
18758            shared
18759                .environment_profiles
18760                .write()
18761                .unwrap_or_else(|poisoned| poisoned.into_inner())
18762                .insert(binding.id.clone(), binding);
18763            shared
18764                .environment_materialization_profiles
18765                .write()
18766                .unwrap_or_else(|poisoned| poisoned.into_inner())
18767                .insert(profile_id.clone(), materialization.unwrap());
18768            receipts.push(ResolvedEnvironmentProfileReceipt {
18769                profile_id,
18770                profile_revision,
18771                network_allowlist: None,
18772                browser_profile_id: None,
18773            });
18774        }
18775        let profile_b = receipts.pop().unwrap();
18776        let profile_a = receipts.pop().unwrap();
18777        (shared, profile_a, profile_b)
18778    }
18779
18780    #[test]
18781    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18782        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18783        let primary = WorkspaceId::new("primary").unwrap();
18784        let worktree = WorkspaceId::new("worktree-b").unwrap();
18785        let address = SessionAddress {
18786            workspace_id: primary.clone(),
18787            session: SessionKey {
18788                instance_id: AgentInstanceId(41),
18789                generation: SessionGeneration(1),
18790            },
18791        };
18792        shared.bind_session_with_policy(
18793            &address,
18794            ProviderRuntimePolicy::raw_pty(),
18795            Some(profile_a.clone()),
18796        );
18797
18798        let refused = shared
18799            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18800            .unwrap_err();
18801        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18802
18803        // Some vs None on the same cwd is also a collision.
18804        let refused_none = shared
18805            .ensure_workspace_environment_profile_compatible(&primary, None)
18806            .unwrap_err();
18807        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18808
18809        // Same profile on the same workspace remains allowed.
18810        shared
18811            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18812            .unwrap();
18813
18814        // Distinct workspace id (managed-worktree parallel path) allows profile B.
18815        shared
18816            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18817            .unwrap();
18818    }
18819
18820    #[tokio::test]
18821    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18822        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18823        let primary = WorkspaceId::new("primary").unwrap();
18824        let address = SessionAddress {
18825            workspace_id: primary.clone(),
18826            session: SessionKey {
18827                instance_id: AgentInstanceId(41),
18828                generation: SessionGeneration(1),
18829            },
18830        };
18831        shared.bind_session_with_policy(
18832            &address,
18833            ProviderRuntimePolicy::raw_pty(),
18834            Some(profile_a),
18835        );
18836
18837        let failure = shared
18838            .spawn_session_with_deadline(
18839                primary,
18840                agent("claude"),
18841                SessionMode::Pty,
18842                gate4agent_types::TerminalSize {
18843                    rows: 24,
18844                    columns: 80,
18845                },
18846                None,
18847                Some(profile_b),
18848                None,
18849                None,
18850                None,
18851                None,
18852                SpawnRecordPolicy::ProviderIdentityOnly,
18853                Some(Instant::now() + Duration::from_secs(30)),
18854                &[],
18855                None,
18856                ApprovalLevel::default(),
18857                None,
18858                None,
18859            )
18860            .await
18861            .unwrap_err();
18862        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18863        // Guard runs before instance id mint / bind.
18864        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18865        assert!(shared.handle.snapshot().sessions.is_empty());
18866    }
18867}
18868
18869#[cfg(test)]
18870mod spawn_characterization_tests {
18871    use super::*;
18872    use crate::session_environment::{
18873        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18874        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18875        NodeSessionPathClass,
18876    };
18877    use gate4agent_catalog::EnvMutation;
18878    use gate4agent_runtime_native::{
18879        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18880        NativeLaunchProfile, NativeLaunchProfileId,
18881    };
18882    use std::ffi::OsString;
18883    use std::sync::Arc;
18884
18885    struct EmptyEnvironmentResolver;
18886
18887    struct FixtureSecretResolver {
18888        deny: Arc<AtomicBool>,
18889    }
18890
18891    impl NodeSecretResolver for FixtureSecretResolver {
18892        fn resolve(
18893            &self,
18894            _reference: &NodeSecretReference,
18895        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18896            if self.deny.load(Ordering::Acquire) {
18897                Err(NodeSecretResolveError::Denied)
18898            } else {
18899                NodeSecretValue::text("fixture-secret")
18900                    .map_err(|_| NodeSecretResolveError::Unavailable)
18901            }
18902        }
18903    }
18904
18905    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18906        fn resolve_child_environment(
18907            &self,
18908        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18909            Ok(vec![EnvMutation {
18910                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18911                value: None,
18912            }])
18913        }
18914    }
18915
18916    fn agent(value: &str) -> AgentId {
18917        AgentId::new(value).unwrap()
18918    }
18919
18920    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
18921        let unique = std::time::SystemTime::now()
18922            .duration_since(std::time::UNIX_EPOCH)
18923            .unwrap()
18924            .as_nanos();
18925        std::env::temp_dir().join(format!(
18926            "gate4agent-node-{label}-{}-{unique}",
18927            std::process::id(),
18928        ))
18929    }
18930
18931    fn materialization_test_shared(
18932        deny_secret: bool,
18933    ) -> (
18934        NodeShared,
18935        NativeRuntime,
18936        ResolvedEnvironmentProfileReceipt,
18937        PathBuf,
18938        Arc<AtomicBool>,
18939    ) {
18940        let workspace = WorkspaceConfig::new(
18941            WorkspaceId::new("primary").unwrap(),
18942            std::env::current_dir().unwrap(),
18943        )
18944        .unwrap();
18945        materialization_test_shared_with_workspace(deny_secret, workspace)
18946    }
18947
18948    fn materialization_test_shared_with_workspace(
18949        deny_secret: bool,
18950        workspace: WorkspaceConfig,
18951    ) -> (
18952        NodeShared,
18953        NativeRuntime,
18954        ResolvedEnvironmentProfileReceipt,
18955        PathBuf,
18956        Arc<AtomicBool>,
18957    ) {
18958        let root = temporary_workspace_root(if deny_secret {
18959            "materialization-denied"
18960        } else {
18961            "materialization-ready"
18962        });
18963        std::fs::create_dir_all(&root).unwrap();
18964        let materialization_root = root.join("session-environments");
18965        let deny = Arc::new(AtomicBool::new(deny_secret));
18966        let resolver = Arc::new(FixtureSecretResolver {
18967            deny: Arc::clone(&deny),
18968        });
18969        let materializer = SessionEnvironmentMaterializer::new(
18970            materialization_root,
18971            resolver,
18972        )
18973        .unwrap();
18974        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
18975        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18976            "materialized-claude-r1",
18977        )
18978        .unwrap();
18979        let materialization = NodeSessionMaterializationProfile::new(
18980            vec![NodeSessionEnvironmentMutation::SetSecret {
18981                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
18982                reference: NodeSecretReference::new("fixture-token").unwrap(),
18983            }],
18984            vec![NodeSessionPathBinding::new(
18985                "GATE4AGENT_PROVIDER_HOME",
18986                NodeSessionPathClass::ProviderHome,
18987            )
18988            .unwrap()],
18989            vec![NodeSessionFile::secret(
18990                NodeSessionPathClass::Config,
18991                "auth/token",
18992                NodeSecretReference::new("fixture-token").unwrap(),
18993            )
18994            .unwrap()],
18995        )
18996        .unwrap();
18997        let catalog = active_registry().unwrap();
18998        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18999        let control = runtime.native_launch_profile_control();
19000        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19001            profile_id.clone(),
19002            profile_revision.clone(),
19003            agent("claude"),
19004            [NativeLaunchProfile::new(
19005                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19006                agent("claude"),
19007                TransportKind::Pty,
19008                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19009                Arc::new(EmptyEnvironmentResolver),
19010            )
19011            .unwrap()],
19012            Some(materialization),
19013        )
19014        .unwrap();
19015        let (binding, native_profiles, materialization) = node_profile.into_parts();
19016        for native_profile in native_profiles {
19017            runtime.upsert_native_launch_profile(native_profile).unwrap();
19018        }
19019        let shared = NodeShared::new_with_incarnation(
19020            handle,
19021            "fixture-token".to_owned(),
19022            NodeId::new("node-materialization-test").unwrap(),
19023            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19024            vec![workspace],
19025            vec![agent("claude")],
19026            ProviderRuntimeStatuses::default(),
19027            None,
19028            Vec::new(),
19029            Vec::new(),
19030            SpawnProfileRegistry::default(),
19031            Some(control),
19032            None,
19033            Some(materializer),
19034            None,
19035            Vec::new(),
19036            Vec::new(),
19037            Vec::new(),
19038            Vec::new(),
19039            None,
19040            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19041        );
19042        shared
19043            .environment_profiles
19044            .write()
19045            .unwrap_or_else(|poisoned| poisoned.into_inner())
19046            .insert(binding.id.clone(), binding);
19047        shared
19048            .environment_materialization_profiles
19049            .write()
19050            .unwrap_or_else(|poisoned| poisoned.into_inner())
19051            .insert(profile_id.clone(), materialization.unwrap());
19052        (
19053            shared,
19054            runtime,
19055            ResolvedEnvironmentProfileReceipt {
19056                profile_id,
19057                profile_revision,
19058                network_allowlist: None,
19059                browser_profile_id: None,
19060            },
19061            root,
19062            deny,
19063        )
19064    }
19065
19066
19067    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19068
19069    async fn characterization_spawn(
19070        shared: &NodeShared,
19071        mode: SessionMode,
19072        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19073        required: &[ProviderRuntimeCapability],
19074        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19075    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19076        shared
19077            .spawn_session_with_deadline(
19078                WorkspaceId::new("primary").unwrap(),
19079                agent("claude"),
19080                mode,
19081                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19082                None,
19083                environment_profile,
19084                None,
19085                None,
19086                None,
19087                Some(ProviderRuntimePolicy::raw_pty()),
19088                SpawnRecordPolicy::ProviderIdentityOnly,
19089                Some(Instant::now() + Duration::from_secs(30)),
19090                required,
19091                harness_mcp,
19092                ApprovalLevel::default(),
19093                None,
19094                None,
19095            )
19096            .await
19097    }
19098
19099    fn assert_no_session_state(shared: &NodeShared) {
19100        assert!(shared.handle.snapshot().sessions.is_empty());
19101        assert!(shared
19102            .session_bindings
19103            .lock()
19104            .unwrap_or_else(|poisoned| poisoned.into_inner())
19105            .is_empty());
19106        assert!(shared
19107            .materializations
19108            .lock()
19109            .unwrap_or_else(|poisoned| poisoned.into_inner())
19110            .is_empty());
19111    }
19112
19113    #[tokio::test]
19114    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19115        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19116        let error = characterization_spawn(
19117            &shared,
19118            SessionMode::Pty,
19119            None,
19120            &[ProviderRuntimeCapability::SemanticReadiness],
19121            None,
19122        )
19123        .await
19124        .unwrap_err();
19125        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19126        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19127        assert_no_session_state(&shared);
19128        drop(shared);
19129        drop(runtime);
19130        std::fs::remove_dir_all(root).unwrap();
19131    }
19132
19133    #[tokio::test]
19134    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19135        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19136        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19137            .await
19138            .unwrap_err();
19139        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19140        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19141        assert_no_session_state(&shared);
19142        drop(shared);
19143        drop(runtime);
19144        std::fs::remove_dir_all(root).unwrap();
19145    }
19146
19147    #[test]
19148    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19149        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19150        let address = SessionAddress {
19151            workspace_id: WorkspaceId::new("primary").unwrap(),
19152            session: SessionKey {
19153                instance_id: AgentInstanceId(77),
19154                generation: SessionGeneration(1),
19155            },
19156        };
19157        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19158        let removed = shared.remove_binding(&address).expect("binding removed");
19159        assert!(removed.environment_profile.is_some());
19160        assert!(shared.remove_binding(&address).is_none());
19161        assert_no_session_state(&shared);
19162        drop(shared);
19163        drop(runtime);
19164        std::fs::remove_dir_all(root).unwrap();
19165    }
19166
19167    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19168        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19169        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19170        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19171            .expect("review current test executable as fixture helper");
19172        let registry = HarnessMcpProxyRegistry::new(helper);
19173        let spec = crate::protocol::SpawnSpec {
19174            target: crate::protocol::SpawnTarget {
19175                node_id: NodeId::new("node-characterization").unwrap(),
19176                workspace_id: WorkspaceId::new("primary").unwrap(),
19177                worktree_id: None,
19178            },
19179            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19180            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19181            overrides: crate::protocol::SpawnOverrides::default(),
19182            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19183            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19184            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19185        };
19186        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19187        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19188        let launch = HarnessMcpLaunchV1 {
19189            server_name: "fixture-harness".to_owned(),
19190            args: vec!["--session-proxy".to_owned()],
19191            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19192            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19193            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19194            trace: None,
19195            scrub_env: Vec::new(),
19196        };
19197        let expires = unix_time_ms().saturating_add(30_000);
19198        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19199        registry
19200            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19201            .unwrap()
19202    }
19203
19204    #[tokio::test]
19205    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19206        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19207        let prepared = characterization_prepared_harness("claude").await;
19208        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19209            .await
19210            .unwrap_err();
19211        // Inline is stopped by runtime admission before the harness check runs.
19212        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19213        let mismatched = characterization_prepared_harness("codex").await;
19214        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19215            .await
19216            .unwrap_err();
19217        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19218        assert_no_session_state(&shared);
19219        drop(shared);
19220        drop(runtime);
19221        std::fs::remove_dir_all(root).unwrap();
19222    }
19223
19224    #[tokio::test]
19225    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19226        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19227        shared.native_launch_profile_control = None;
19228        let prepared = characterization_prepared_harness("claude").await;
19229        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19230            .await
19231            .unwrap_err();
19232        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19233        assert_no_session_state(&shared);
19234        drop(shared);
19235        drop(runtime);
19236        std::fs::remove_dir_all(root).unwrap();
19237    }
19238}
19239
19240#[cfg(all(test, windows))]
19241mod tests {
19242    use super::*;
19243    use gate4agent_types::ProviderActivity;
19244    use crate::session_environment::{
19245        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19246        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19247        NodeSessionPathClass,
19248    };
19249    use gate4agent_catalog::EnvMutation;
19250    use gate4agent_runtime_native::{
19251        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19252        NativeLaunchProfile, NativeLaunchProfileId,
19253    };
19254    use std::ffi::OsString;
19255    use std::sync::Arc;
19256
19257    struct EmptyEnvironmentResolver;
19258
19259    struct FixtureSecretResolver {
19260        deny: Arc<AtomicBool>,
19261    }
19262
19263    impl NodeSecretResolver for FixtureSecretResolver {
19264        fn resolve(
19265            &self,
19266            _reference: &NodeSecretReference,
19267        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19268            if self.deny.load(Ordering::Acquire) {
19269                Err(NodeSecretResolveError::Denied)
19270            } else {
19271                NodeSecretValue::text("fixture-secret")
19272                    .map_err(|_| NodeSecretResolveError::Unavailable)
19273            }
19274        }
19275    }
19276
19277    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19278        fn resolve_child_environment(
19279            &self,
19280        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19281            Ok(vec![EnvMutation {
19282                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19283                value: None,
19284            }])
19285        }
19286    }
19287
19288    fn agent(value: &str) -> AgentId {
19289        AgentId::new(value).unwrap()
19290    }
19291
19292    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19293        let catalog = active_registry().unwrap();
19294        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19295        let workspace = WorkspaceConfig::new(
19296            WorkspaceId::new("primary").unwrap(),
19297            std::env::current_dir().unwrap(),
19298        )
19299        .unwrap()
19300        .with_worktree_service_mode(mode);
19301        NodeShared::new(
19302            handle,
19303            "fixture-token".to_owned(),
19304            NodeId::new("node-terminal-test").unwrap(),
19305            vec![workspace],
19306            vec![agent("claude")],
19307        )
19308    }
19309
19310    fn terminal_test_shared() -> NodeShared {
19311        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19312    }
19313
19314    #[cfg(feature = "fixture")]
19315    #[test]
19316    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19317        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19318        assert_eq!(probe.latest_code(), None);
19319
19320        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19321        assert_eq!(
19322            probe.latest_code(),
19323            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19324        );
19325
19326        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19327        assert_eq!(
19328            probe.latest_code(),
19329            Some(NodeFailureCode::SpawnDeadlineExceeded),
19330        );
19331
19332        probe.record(None);
19333        assert_eq!(probe.latest_code(), None);
19334    }
19335
19336    #[test]
19337    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19338        let source = temporary_workspace_root("launch-inventory-source");
19339        let allocation = temporary_workspace_root("launch-inventory-allocation");
19340        std::fs::create_dir_all(&source).unwrap();
19341        std::fs::create_dir_all(&allocation).unwrap();
19342        let workspace_id = WorkspaceId::new("primary").unwrap();
19343        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19344            .unwrap()
19345            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19346        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19347            workspace = workspace
19348                .with_managed_worktree_profile(
19349                    ManagedWorktreeProfile::new(
19350                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19351                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19352                        &allocation,
19353                        "gate4agent",
19354                        "HEAD",
19355                        ManagedWorktreeRetention::RemoveWhenReleased,
19356                    )
19357                    .unwrap(),
19358                )
19359                .unwrap();
19360        }
19361        let overflow = workspace.clone().with_managed_worktree_profile(
19362            ManagedWorktreeProfile::new(
19363                WorktreeProfileId::new("overflow").unwrap(),
19364                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19365                &allocation,
19366                "gate4agent",
19367                "HEAD",
19368                ManagedWorktreeRetention::Retain,
19369            )
19370            .unwrap(),
19371        );
19372        assert!(matches!(
19373            overflow,
19374            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19375                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19376                ..
19377            }),
19378        ));
19379
19380        let catalog = active_registry().unwrap();
19381        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19382        let shared = NodeShared::new(
19383            handle,
19384            "fixture-token".to_owned(),
19385            NodeId::new("node-launch-inventory").unwrap(),
19386            vec![workspace],
19387            vec![agent("claude")],
19388        );
19389        let snapshot = shared.snapshot();
19390        let launch = snapshot.launch_inventory.as_ref().unwrap();
19391        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19392        assert!(launch.bundles.as_ref().unwrap().is_empty());
19393        let profiles = snapshot.workspaces[0]
19394            .managed_worktree_profiles
19395            .as_ref()
19396            .unwrap();
19397        assert_eq!(
19398            snapshot.workspaces[0].worktree_service_mode,
19399            Some(WorktreeServiceMode::Managed),
19400        );
19401        assert_eq!(
19402            profiles.profiles.len(),
19403            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19404        );
19405        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19406        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19407            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19408        }
19409        let legacy = snapshot_for_wire(
19410            &shared,
19411            true,
19412            true,
19413            false,
19414            false,
19415            true,
19416            false,
19417            true,
19418            false,
19419            true,
19420        );
19421        assert!(legacy.launch_inventory.is_none());
19422        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19423
19424        drop(shared);
19425        std::fs::remove_dir_all(source).unwrap();
19426        std::fs::remove_dir_all(allocation).unwrap();
19427    }
19428
19429    #[test]
19430    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19431        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19432            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19433        }));
19434        let source = gate4agent_types::ProviderSource {
19435            family: AdapterFamily::PtySemantic,
19436            binding: AdapterBinding::new(
19437                gate4agent_types::AdapterId::new("claude").unwrap(),
19438                "fixture/v1",
19439                gate4agent_types::AdapterVerification::SyntheticFixture,
19440            )
19441            .unwrap(),
19442        };
19443        let provider = ProviderSnapshot {
19444            sequence: 17,
19445            session: Some(ProviderSessionIdentity {
19446                key: ProviderSessionKey::SessionId,
19447                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19448                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19449            }),
19450            model: Some("PRIVATE_MODEL".to_owned()),
19451            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19452            completed_turns: 3,
19453            usage: gate4agent_types::TokenUsage {
19454                input_tokens: 10,
19455                output_tokens: 20,
19456                cache_read_tokens: 30,
19457                cache_write_tokens: 40,
19458                reasoning_tokens: 50,
19459                context_window: Some(200_000),
19460            },
19461            lead_activity: ProviderActivity::WaitingForInput,
19462            activity: ProviderActivity::WaitingForInput,
19463            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19464            active_tools: (0..9)
19465                .map(|index| gate4agent_types::ActiveProviderTool {
19466                    id: format!("private-tool-id-{index}"),
19467                    name: if index == 0 {
19468                        format!("  Read\u{0000}{}", "x".repeat(80))
19469                    } else {
19470                        format!("Tool-{index}")
19471                    },
19472                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19473                })
19474                .collect(),
19475            interactions: vec![gate4agent_types::ProviderInteraction {
19476                id: gate4agent_types::ProviderInteractionId(17),
19477                source: source.clone(),
19478                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19479                interaction_kind: ProviderInteractionKind::Approval,
19480                tool_name: " Approve\u{0007}Tool ".to_owned(),
19481                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19482                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19483                resume_lead_activity: Some(ProviderActivity::Working),
19484                status: ProviderInteractionStatus::Pending,
19485            }],
19486            subagents: vec![gate4agent_types::ProviderSubagent {
19487                source: source.clone(),
19488                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19489                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19490                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19491            }],
19492            sources: vec![gate4agent_types::ProviderSourceCursor {
19493                source,
19494                sequence: 17,
19495                gap_count: 2,
19496                stale: true,
19497            }],
19498            last_event: Some(ProviderEvent::Error {
19499                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19500            }),
19501            gap_count: 2,
19502            stale: true,
19503        };
19504        let address = terminal_address(3);
19505        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19506        assert_eq!(entry.address, address);
19507        assert_eq!(entry.progress.provider_sequence, 17);
19508        assert_eq!(entry.progress.active_tool_count, 9);
19509        assert_eq!(
19510            entry.progress.active_tool_labels.len(),
19511            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19512        );
19513        assert_eq!(entry.progress.subagent_count, 1);
19514        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19515        assert!(entry.progress.truncated);
19516        let encoded = serde_json::to_string(&entry).unwrap();
19517        for forbidden in [
19518            "PRIVATE_PROVIDER_SESSION",
19519            "PRIVATE_PROVIDER_PATH",
19520            "PRIVATE_MODEL",
19521            "PRIVATE_TOOL_INVENTORY",
19522            "PRIVATE_CURRENT_PROMPT",
19523            "PRIVATE_TOOL_INPUT_JSON",
19524            "PRIVATE_PROVIDER_REQUEST",
19525            "PRIVATE_INTERACTION_PROMPT",
19526            "PRIVATE_INTERACTION_AGENT",
19527            "PRIVATE_SUBAGENT_ID",
19528            "PRIVATE_SUBAGENT_TYPE",
19529            "PRIVATE_SUBAGENT_DESCRIPTION",
19530            "PRIVATE_PROVIDER_ERROR",
19531            "input_json",
19532            "current_prompt",
19533            "session_id",
19534        ] {
19535            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19536        }
19537
19538        let snapshot = NodeSnapshot {
19539            node_id: NodeId::new("node-progress-test").unwrap(),
19540            enabled_providers: vec![agent("claude")],
19541            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19542            workspaces: Vec::new(),
19543            session_records: Vec::new(),
19544            managed_worktrees: Vec::new(),
19545            launch_inventory: None,
19546            agent_progress: vec![entry],
19547        };
19548        let mut legacy_reply = ResponseEnvelope {
19549            request_id: 1,
19550            result: Ok(NodeResponse::Snapshot {
19551                event_sequence: 0,
19552                controller: None,
19553                snapshot: snapshot.clone(),
19554            }),
19555        };
19556        project_response_without_agent_progress(&mut legacy_reply);
19557        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19558            panic!("agent progress projection changed response kind");
19559        };
19560        assert!(legacy.agent_progress.is_empty());
19561        assert_eq!(snapshot.agent_progress.len(), 1);
19562    }
19563
19564    fn terminal_address(generation: u64) -> SessionAddress {
19565        SessionAddress {
19566            workspace_id: WorkspaceId::new("primary").unwrap(),
19567            session: SessionKey {
19568                instance_id: AgentInstanceId(41),
19569                generation: SessionGeneration(generation),
19570            },
19571        }
19572    }
19573
19574    fn spawn_spec_fixture() -> SpawnSpec {
19575        SpawnSpec {
19576            target: crate::protocol::SpawnTarget {
19577                node_id: NodeId::new("node-terminal-test").unwrap(),
19578                workspace_id: WorkspaceId::new("primary").unwrap(),
19579                worktree_id: None,
19580            },
19581            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19582            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19583                .unwrap(),
19584            overrides: crate::protocol::SpawnOverrides::default(),
19585            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19586            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19587            required_capabilities: SpawnRequiredCapabilities::default(),
19588        }
19589    }
19590
19591    fn environment_profile_test_shared(
19592    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19593        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19594        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19595            "local-claude-r1",
19596        )
19597        .unwrap();
19598        let spawn_profiles = SpawnProfileRegistry::new([
19599            crate::protocol::SpawnProfileDefaults {
19600                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19601                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19602                provider: agent("claude"),
19603                mode: SessionMode::Pty,
19604                terminal_size: gate4agent_types::TerminalSize {
19605                    rows: 24,
19606                    columns: 80,
19607                },
19608                prompt: None,
19609                bundle_id: None,
19610                context_id: None,
19611                environment_profile_id: Some(profile_id.clone()),
19612            },
19613        ])
19614        .unwrap();
19615        let catalog = active_registry().unwrap();
19616        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19617        let control = runtime.native_launch_profile_control();
19618        let node_profile = NodeEnvironmentProfile::new(
19619            profile_id.clone(),
19620            profile_revision.clone(),
19621            agent("claude"),
19622            [NativeLaunchProfile::new(
19623                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19624                agent("claude"),
19625                TransportKind::Pty,
19626                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19627                Arc::new(EmptyEnvironmentResolver),
19628            )
19629            .unwrap()],
19630        )
19631        .unwrap();
19632        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19633        for native_profile in native_profiles {
19634            runtime.upsert_native_launch_profile(native_profile).unwrap();
19635        }
19636        let workspace = WorkspaceConfig::new(
19637            WorkspaceId::new("primary").unwrap(),
19638            std::env::current_dir().unwrap(),
19639        )
19640        .unwrap();
19641        let shared = NodeShared::new_with_incarnation(
19642            handle,
19643            "fixture-token".to_owned(),
19644            NodeId::new("node-terminal-test").unwrap(),
19645            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19646            vec![workspace],
19647            vec![agent("claude")],
19648            ProviderRuntimeStatuses::default(),
19649            None,
19650            Vec::new(),
19651            Vec::new(),
19652            spawn_profiles,
19653            Some(control),
19654            None,
19655            None,
19656            None,
19657            Vec::new(),
19658            Vec::new(),
19659            Vec::new(),
19660            Vec::new(),
19661            None,
19662            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19663        );
19664        shared
19665            .environment_profiles
19666            .write()
19667            .unwrap_or_else(|poisoned| poisoned.into_inner())
19668            .insert(binding.id.clone(), binding);
19669        (
19670            shared,
19671            runtime,
19672            ResolvedEnvironmentProfileReceipt {
19673                profile_id,
19674                profile_revision,
19675                network_allowlist: None,
19676                browser_profile_id: None,
19677            },
19678        )
19679    }
19680
19681    fn materialization_test_shared(
19682        deny_secret: bool,
19683    ) -> (
19684        NodeShared,
19685        NativeRuntime,
19686        ResolvedEnvironmentProfileReceipt,
19687        PathBuf,
19688        Arc<AtomicBool>,
19689    ) {
19690        let workspace = WorkspaceConfig::new(
19691            WorkspaceId::new("primary").unwrap(),
19692            std::env::current_dir().unwrap(),
19693        )
19694        .unwrap();
19695        materialization_test_shared_with_workspace(deny_secret, workspace)
19696    }
19697
19698    fn materialization_test_shared_with_workspace(
19699        deny_secret: bool,
19700        workspace: WorkspaceConfig,
19701    ) -> (
19702        NodeShared,
19703        NativeRuntime,
19704        ResolvedEnvironmentProfileReceipt,
19705        PathBuf,
19706        Arc<AtomicBool>,
19707    ) {
19708        let root = temporary_workspace_root(if deny_secret {
19709            "materialization-denied"
19710        } else {
19711            "materialization-ready"
19712        });
19713        std::fs::create_dir_all(&root).unwrap();
19714        let materialization_root = root.join("session-environments");
19715        let deny = Arc::new(AtomicBool::new(deny_secret));
19716        let resolver = Arc::new(FixtureSecretResolver {
19717            deny: Arc::clone(&deny),
19718        });
19719        let materializer = SessionEnvironmentMaterializer::new(
19720            materialization_root,
19721            resolver,
19722        )
19723        .unwrap();
19724        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19725        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19726            "materialized-claude-r1",
19727        )
19728        .unwrap();
19729        let materialization = NodeSessionMaterializationProfile::new(
19730            vec![NodeSessionEnvironmentMutation::SetSecret {
19731                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19732                reference: NodeSecretReference::new("fixture-token").unwrap(),
19733            }],
19734            vec![NodeSessionPathBinding::new(
19735                "GATE4AGENT_PROVIDER_HOME",
19736                NodeSessionPathClass::ProviderHome,
19737            )
19738            .unwrap()],
19739            vec![NodeSessionFile::secret(
19740                NodeSessionPathClass::Config,
19741                "auth/token",
19742                NodeSecretReference::new("fixture-token").unwrap(),
19743            )
19744            .unwrap()],
19745        )
19746        .unwrap();
19747        let catalog = active_registry().unwrap();
19748        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19749        let control = runtime.native_launch_profile_control();
19750        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19751            profile_id.clone(),
19752            profile_revision.clone(),
19753            agent("claude"),
19754            [NativeLaunchProfile::new(
19755                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19756                agent("claude"),
19757                TransportKind::Pty,
19758                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19759                Arc::new(EmptyEnvironmentResolver),
19760            )
19761            .unwrap()],
19762            Some(materialization),
19763        )
19764        .unwrap();
19765        let (binding, native_profiles, materialization) = node_profile.into_parts();
19766        for native_profile in native_profiles {
19767            runtime.upsert_native_launch_profile(native_profile).unwrap();
19768        }
19769        let shared = NodeShared::new_with_incarnation(
19770            handle,
19771            "fixture-token".to_owned(),
19772            NodeId::new("node-materialization-test").unwrap(),
19773            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19774            vec![workspace],
19775            vec![agent("claude")],
19776            ProviderRuntimeStatuses::default(),
19777            None,
19778            Vec::new(),
19779            Vec::new(),
19780            SpawnProfileRegistry::default(),
19781            Some(control),
19782            None,
19783            Some(materializer),
19784            None,
19785            Vec::new(),
19786            Vec::new(),
19787            Vec::new(),
19788            Vec::new(),
19789            None,
19790            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19791        );
19792        shared
19793            .environment_profiles
19794            .write()
19795            .unwrap_or_else(|poisoned| poisoned.into_inner())
19796            .insert(binding.id.clone(), binding);
19797        shared
19798            .environment_materialization_profiles
19799            .write()
19800            .unwrap_or_else(|poisoned| poisoned.into_inner())
19801            .insert(profile_id.clone(), materialization.unwrap());
19802        (
19803            shared,
19804            runtime,
19805            ResolvedEnvironmentProfileReceipt {
19806                profile_id,
19807                profile_revision,
19808                network_allowlist: None,
19809                browser_profile_id: None,
19810            },
19811            root,
19812            deny,
19813        )
19814    }
19815
19816    #[cfg(feature = "fixture")]
19817    #[derive(Clone, Copy)]
19818    enum CodexHomeProfile {
19819        Missing,
19820        WrongClass,
19821        Exact,
19822    }
19823
19824    #[cfg(feature = "fixture")]
19825    fn codex_bundle_test_shared(
19826        home_profile: CodexHomeProfile,
19827    ) -> (
19828        NodeShared,
19829        NativeRuntime,
19830        ResolvedEnvironmentProfileReceipt,
19831        NodeBundle,
19832        PathBuf,
19833    ) {
19834        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19835        const CLAUDE_MANIFEST: &[u8] =
19836            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19837        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19838        const DIGEST: &str =
19839            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19840
19841        let (shared, mut runtime, _claude_receipt, root, _deny) =
19842            materialization_test_shared(false);
19843        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19844            CodexHomeProfile::Missing => "codex-home-missing",
19845            CodexHomeProfile::WrongClass => "codex-home-wrong",
19846            CodexHomeProfile::Exact => "codex-home-exact",
19847        })
19848        .unwrap();
19849        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19850            "codex-home-r1",
19851        )
19852        .unwrap();
19853        let materialization = match home_profile {
19854            CodexHomeProfile::Missing => None,
19855            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19856                Some(NodeSessionMaterializationProfile::new(
19857                    Vec::new(),
19858                    vec![NodeSessionPathBinding::new(
19859                        "CODEX_HOME",
19860                        if matches!(home_profile, CodexHomeProfile::Exact) {
19861                            NodeSessionPathClass::ProviderHome
19862                        } else {
19863                            NodeSessionPathClass::Config
19864                        },
19865                    )
19866                    .unwrap()],
19867                    Vec::new(),
19868                )
19869                .unwrap())
19870            }
19871        };
19872        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19873            profile_id.clone(),
19874            profile_revision.clone(),
19875            agent("codex"),
19876            [NativeLaunchProfile::new(
19877                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19878                agent("codex"),
19879                TransportKind::Pty,
19880                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19881                Arc::new(EmptyEnvironmentResolver),
19882            )
19883            .unwrap()],
19884            materialization,
19885        )
19886        .unwrap();
19887        let (binding, native_profiles, materialization) = node_profile.into_parts();
19888        for native_profile in native_profiles {
19889            runtime.upsert_native_launch_profile(native_profile).unwrap();
19890        }
19891        shared
19892            .environment_profiles
19893            .write()
19894            .unwrap_or_else(|poisoned| poisoned.into_inner())
19895            .insert(binding.id.clone(), binding);
19896        if let Some(materialization) = materialization {
19897            shared
19898                .environment_materialization_profiles
19899                .write()
19900                .unwrap_or_else(|poisoned| poisoned.into_inner())
19901                .insert(profile_id.clone(), materialization);
19902        }
19903
19904        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19905        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19906        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19907        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19908        std::fs::write(
19909            bundle_source.join(".claude-plugin/plugin.json"),
19910            CLAUDE_MANIFEST,
19911        )
19912        .unwrap();
19913        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19914        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19915        let bundle = NodeBundle::new(
19916            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19917            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19918            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19919            &bundle_source,
19920        )
19921        .unwrap();
19922        *shared
19923            .bundle_catalog
19924            .write()
19925            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19926            BundleCatalog::new([bundle.clone()]).unwrap();
19927        (
19928            shared,
19929            runtime,
19930            ResolvedEnvironmentProfileReceipt {
19931                profile_id,
19932                profile_revision,
19933                network_allowlist: None,
19934                browser_profile_id: None,
19935            },
19936            bundle,
19937            root,
19938        )
19939    }
19940
19941    #[cfg(feature = "fixture")]
19942    #[test]
19943    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19944        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19945            let (shared, runtime, receipt, bundle, root) =
19946                codex_bundle_test_shared(home_profile);
19947            let error = shared
19948                .bundle_layout(
19949                    &agent("codex"),
19950                    SessionMode::Pty,
19951                    Some(&receipt),
19952                    &bundle,
19953                )
19954                .unwrap_err();
19955            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19956            assert!(shared
19957                .materializations
19958                .lock()
19959                .unwrap_or_else(|poisoned| poisoned.into_inner())
19960                .is_empty());
19961            let materialization_root = root.join("session-environments");
19962            assert!(!std::fs::read_dir(&materialization_root)
19963                .unwrap()
19964                .filter_map(Result::ok)
19965                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19966            drop(shared);
19967            drop(runtime);
19968            let _ = std::fs::remove_dir_all(root);
19969        }
19970    }
19971
19972    #[cfg(feature = "fixture")]
19973    #[test]
19974    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19975        let (shared, runtime, receipt, bundle, root) =
19976            codex_bundle_test_shared(CodexHomeProfile::Exact);
19977        let address = SessionAddress {
19978            workspace_id: WorkspaceId::new("primary").unwrap(),
19979            session: SessionKey {
19980                instance_id: AgentInstanceId(310),
19981                generation: SessionGeneration(1),
19982            },
19983        };
19984        let (overlay, guard) = shared
19985            .prepare_session_materialization(
19986                &address,
19987                &agent("codex"),
19988                SessionMode::Pty,
19989                Some(&receipt),
19990                Some(&bundle.receipt()),
19991                None,
19992                None,
19993            )
19994            .unwrap()
19995            .unwrap();
19996        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19997        let ownership = shared
19998            .materializations
19999            .lock()
20000            .unwrap_or_else(|poisoned| poisoned.into_inner())
20001            .get(guard.id().unwrap())
20002            .unwrap()
20003            .clone();
20004        let (profile, _, _) = shared
20005            .materialization_profile(
20006                &agent("codex"),
20007                SessionMode::Pty,
20008                Some(&receipt),
20009                Some(&bundle.receipt()),
20010                None,
20011            )
20012            .unwrap()
20013            .unwrap();
20014        let environment = shared
20015            .session_environment_materializer
20016            .as_ref()
20017            .unwrap()
20018            .resolve_environment(&ownership, &profile)
20019            .unwrap();
20020        assert!(environment.iter().any(|mutation| {
20021            mutation.key == OsString::from("CODEX_HOME")
20022                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20023        }));
20024        assert_eq!(
20025            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20026                .unwrap(),
20027            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20028        );
20029        assert!(std::fs::read_dir(ownership.bundle_root())
20030            .unwrap()
20031            .next()
20032            .is_none());
20033        drop(guard);
20034        drop(shared);
20035        drop(runtime);
20036        let _ = std::fs::remove_dir_all(root);
20037    }
20038
20039    #[cfg(feature = "fixture")]
20040    #[test]
20041    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20042        let (shared, runtime, receipt, bundle, root) =
20043            codex_bundle_test_shared(CodexHomeProfile::Exact);
20044        let bundle_receipt = bundle.receipt();
20045        let address = SessionAddress {
20046            workspace_id: WorkspaceId::new("primary").unwrap(),
20047            session: SessionKey {
20048                instance_id: AgentInstanceId(311),
20049                generation: SessionGeneration(1),
20050            },
20051        };
20052        let (_overlay, guard) = shared
20053            .prepare_session_materialization(
20054                &address,
20055                &agent("codex"),
20056                SessionMode::Pty,
20057                Some(&receipt),
20058                Some(&bundle_receipt),
20059                None,
20060                None,
20061            )
20062            .unwrap()
20063            .unwrap();
20064        let materialization_id = guard.id().unwrap().clone();
20065        let skill_path = shared
20066            .materializations
20067            .lock()
20068            .unwrap_or_else(|poisoned| poisoned.into_inner())
20069            .get(&materialization_id)
20070            .unwrap()
20071            .provider_home()
20072            .join("skills/review-code/SKILL.md");
20073        let record_id = shared
20074            .bind_spawn_session_with_materialization(
20075                &address,
20076                agent("codex"),
20077                SessionMode::Pty,
20078                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20079                SpawnRecordPolicy::ProviderIdentityOnly,
20080                Some(receipt.clone()),
20081                Some(bundle_receipt.clone()),
20082                None,
20083                Some(materialization_id.clone()),
20084            )
20085            .unwrap()
20086            .unwrap();
20087        guard.retain();
20088        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20089
20090        let error = match shared.resolve_record_materialization(
20091                &record_id,
20092                &agent("codex"),
20093                SessionMode::Pty,
20094                Some(&receipt),
20095                Some(&bundle_receipt),
20096                None,
20097            ) {
20098                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20099                Err(error) => error,
20100            };
20101        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20102        assert_eq!(
20103            shared
20104                .materializations
20105                .lock()
20106                .unwrap_or_else(|poisoned| poisoned.into_inner())
20107                .get(&materialization_id)
20108                .unwrap()
20109                .state(),
20110            MaterializationState::RecoveryRequired,
20111        );
20112        drop(shared);
20113        drop(runtime);
20114        let _ = std::fs::remove_dir_all(root);
20115    }
20116
20117    fn bind_record_owned_materialization_fixture(
20118        shared: &NodeShared,
20119        receipt: &ResolvedEnvironmentProfileReceipt,
20120        instance_id: u64,
20121    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20122        let address = SessionAddress {
20123            workspace_id: WorkspaceId::new("primary").unwrap(),
20124            session: SessionKey {
20125                instance_id: AgentInstanceId(instance_id),
20126                generation: SessionGeneration(1),
20127            },
20128        };
20129        let (overlay, materialization_guard) = shared
20130            .prepare_session_materialization(
20131                &address,
20132                &agent("claude"),
20133                SessionMode::Pty,
20134                Some(receipt),
20135                None,
20136                None,
20137                None,
20138            )
20139            .unwrap()
20140            .unwrap();
20141        let materialization_id = materialization_guard.id().unwrap().clone();
20142        let materialization_root = shared
20143            .materializations
20144            .lock()
20145            .unwrap_or_else(|poisoned| poisoned.into_inner())
20146            .get(&materialization_id)
20147            .unwrap()
20148            .root()
20149            .to_path_buf();
20150        let selection = shared
20151            .select_environment_profile(
20152                address.session.instance_id,
20153                &agent("claude"),
20154                SessionMode::Pty,
20155                Some(receipt),
20156            )
20157            .unwrap()
20158            .unwrap();
20159        assert!(shared
20160            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20161            .unwrap()
20162            .is_none());
20163        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20164        let record_id = shared
20165            .bind_spawn_session_with_materialization(
20166                &address,
20167                agent("claude"),
20168                SessionMode::Pty,
20169                policy,
20170                SpawnRecordPolicy::ProviderIdentityOnly,
20171                Some(receipt.clone()),
20172                None,
20173                None,
20174                Some(materialization_id.clone()),
20175            )
20176            .unwrap()
20177            .unwrap();
20178        selection.retain();
20179        materialization_guard.retain();
20180        (address, record_id, materialization_id, materialization_root)
20181    }
20182
20183    #[test]
20184    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20185        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20186        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20187            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20188        }));
20189        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20190        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20191        let mut spec = spawn_spec_fixture();
20192        spec.expected_profile_revision =
20193            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20194        assert!(matches!(
20195            &spec.overrides.environment_profile_id,
20196            crate::protocol::SpawnOverride::Inherit,
20197        ));
20198        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20199            .requires_child_environment_profile_capability());
20200        assert!(request_requires_child_environment_profile(
20201            &shared,
20202            &NodeRequest::SpawnSpec { spec: spec.clone() },
20203        ));
20204
20205        let address = terminal_address(1);
20206        shared.bind_session_with_policy(
20207            &address,
20208            ProviderRuntimePolicy::raw_pty(),
20209            Some(environment_profile.clone()),
20210        );
20211        assert!(request_requires_child_environment_profile(
20212            &shared,
20213            &NodeRequest::Input {
20214                session: address.clone(),
20215                text: "x".to_owned(),
20216            },
20217        ));
20218
20219        let mut record = record("claude", "profile-record");
20220        record.active_session = Some(address.clone());
20221        record.environment_profile = Some(environment_profile.clone());
20222        let record_id = record.record_id.clone();
20223        shared.insert_record(record.clone()).unwrap();
20224        assert!(request_requires_child_environment_profile(
20225            &shared,
20226            &NodeRequest::ResumeSessionRecord {
20227                record_id,
20228                terminal_size: gate4agent_types::TerminalSize {
20229                    rows: 24,
20230                    columns: 80,
20231                },
20232                initial_prompt: None,
20233            },
20234        ));
20235
20236        let snapshot = snapshot_for_wire(
20237            &shared, false, true, true, true, false, false, false, false, true,
20238        );
20239        assert_eq!(snapshot.session_records.len(), 1);
20240        assert!(snapshot.session_records[0].environment_profile.is_none());
20241        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20242            sequence: 1,
20243            event: NodeEvent::SessionRecordUpserted { record },
20244        });
20245        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20246            panic!("record projection changed the event kind");
20247        };
20248        assert!(record.environment_profile.is_none());
20249
20250        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20251        let mut reply = ResponseEnvelope {
20252            request_id: 1,
20253            result: Ok(NodeResponse::SpawnSpecAccepted {
20254                receipt: resolved.receipt_with_environment(
20255                    shared.incarnation_id,
20256                    address,
20257                    Some(environment_profile),
20258                ),
20259            }),
20260        };
20261        project_response_without_child_environment_profile(&mut reply);
20262        assert_eq!(
20263            reply.result.unwrap_err().code,
20264            NodeFailureCode::UnsupportedCapability,
20265        );
20266    }
20267
20268    #[test]
20269    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20270        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20271        let snapshot = shared.snapshot();
20272        let profiles = snapshot
20273            .launch_inventory
20274            .as_ref()
20275            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20276            .unwrap();
20277        assert_eq!(profiles.len(), 1);
20278        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20279        assert!(snapshot.requires_child_environment_profile_capability());
20280
20281        let mut legacy = snapshot.clone();
20282        clear_snapshot_child_environment_profiles(&mut legacy);
20283        assert!(legacy
20284            .launch_inventory
20285            .as_ref()
20286            .unwrap()
20287            .spawn_profiles
20288            .as_ref()
20289            .unwrap()[0]
20290            .environment_profile
20291            .is_none());
20292        assert!(!legacy.requires_child_environment_profile_capability());
20293
20294        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20295        let bindings = shared
20296            .environment_profiles
20297            .read()
20298            .unwrap_or_else(|poisoned| poisoned.into_inner())
20299            .clone();
20300        let mut provider_mismatch = bindings.clone();
20301        provider_mismatch
20302            .values_mut()
20303            .next()
20304            .unwrap()
20305            .provider = agent("codex");
20306        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20307        let mut mode_mismatch = profile;
20308        mode_mismatch.mode = SessionMode::Inline;
20309        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20310
20311        shared
20312            .environment_profiles
20313            .write()
20314            .unwrap_or_else(|poisoned| poisoned.into_inner())
20315            .clear();
20316        assert!(shared
20317            .snapshot()
20318            .launch_inventory
20319            .unwrap()
20320            .spawn_profiles
20321            .unwrap()
20322            .is_empty());
20323    }
20324
20325    #[test]
20326    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20327        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20328            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20329        }));
20330        let shared = terminal_test_shared();
20331        let bundle = ResolvedBundleReceipt {
20332            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20333            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20334            digest: crate::protocol::SpawnBundleDigest::new(format!(
20335                "sha256:{}",
20336                "0".repeat(64),
20337            ))
20338            .unwrap(),
20339        };
20340        let mut bundled = record("claude", "bundle-record");
20341        bundled.bundle = Some(bundle.clone());
20342        let record_id = bundled.record_id.clone();
20343        shared.insert_record(bundled.clone()).unwrap();
20344        assert!(request_requires_session_bundle(
20345            &shared,
20346            &NodeRequest::ForgetSessionRecord {
20347                record_id: record_id.clone(),
20348            },
20349        ));
20350
20351        let snapshot = snapshot_for_wire(
20352            &shared, false, true, true, true, true, false, false, false, true,
20353        );
20354        assert!(snapshot.session_records[0].bundle.is_none());
20355        let launch = snapshot.launch_inventory.as_ref().unwrap();
20356        assert!(launch.spawn_profiles.is_some());
20357        assert!(launch.bundles.is_none());
20358        let event = project_event_without_session_bundle(NodeEventEnvelope {
20359            sequence: 1,
20360            event: NodeEvent::SessionRecordUpserted { record: bundled },
20361        });
20362        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20363            panic!("record projection changed the event kind");
20364        };
20365        assert!(record.bundle.is_none());
20366
20367        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20368        let mut reply = ResponseEnvelope {
20369            request_id: 1,
20370            result: Ok(NodeResponse::SpawnSpecAccepted {
20371                receipt: resolved.receipt_with_materialization(
20372                    shared.incarnation_id,
20373                    terminal_address(17),
20374                    None,
20375                    Some(bundle),
20376                    None,
20377                ),
20378            }),
20379        };
20380        project_response_without_session_bundle(&mut reply);
20381        assert_eq!(
20382            reply.result.unwrap_err().code,
20383            NodeFailureCode::UnsupportedCapability,
20384        );
20385    }
20386
20387    #[test]
20388    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20389        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20390            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20391        }));
20392        let context_id = SpawnContextId::new("context-review").unwrap();
20393        let context = ResolvedContextPackReceipt {
20394            id: context_id.clone(),
20395            digest: crate::protocol::SpawnContextDigest::new(format!(
20396                "sha256:{}",
20397                "c".repeat(64),
20398            ))
20399            .unwrap(),
20400            lineage: ContextPackLineageReceipt {
20401                source_node_id: NodeId::new("node-source").unwrap(),
20402                source_session: terminal_address(1),
20403                source_provider: agent("kimi"),
20404            },
20405            source_message_count: 2,
20406            retained_message_count: 2,
20407            byte_len: 256,
20408            truncated: false,
20409        };
20410        assert!(context.is_valid());
20411        let mut bound_record = record("codex", "context-record");
20412        bound_record.context_id = Some(context_id);
20413        bound_record.context = Some(context.clone());
20414        let session = gate4agent_types::SessionSnapshot {
20415            instance_id: AgentInstanceId(91),
20416            agent_id: agent("kimi"),
20417            transport: TransportKind::Pty,
20418            generation: SessionGeneration(3),
20419            status: SessionStatus::Running,
20420            pending_operation: None,
20421            pending_input: None,
20422            process_id: Some(9001),
20423            terminal_size: None,
20424            terminal_frame: None,
20425            terminal_stale: None,
20426            session_options: None,
20427            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20428            history: gate4agent_types::HistorySnapshot {
20429                pending: None,
20430                candidates: vec![HistoryCandidateSummary {
20431                    id: "candidate-opaque".to_owned(),
20432                    session_id_hint: "session-hint".to_owned(),
20433                    modified_at_unix_ms: Some(1),
20434                }],
20435                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20436                loaded: Some(HistorySessionRecord {
20437                    session_id: "private-vendor-session".to_owned(),
20438                    title: Some("private title".to_owned()),
20439                    cwd: Some(r"C:\private\history-root".to_owned()),
20440                    model: Some("private-model".to_owned()),
20441                    message_count: 2,
20442                    completed_turn_count: None,
20443                    total_tokens: 19,
20444                    messages: vec![
20445                        gate4agent_types::HistoryMessageRecord {
20446                            role: gate4agent_types::HistoryMessageRole::User,
20447                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20448                        },
20449                        gate4agent_types::HistoryMessageRecord {
20450                            role: gate4agent_types::HistoryMessageRole::Assistant,
20451                            text: "private answer".to_owned(),
20452                        },
20453                    ],
20454                }),
20455                last_error: None,
20456            },
20457            resume: gate4agent_types::ResumeSnapshot::default(),
20458            foreground: gate4agent_types::ForegroundSnapshot::default(),
20459            provider: gate4agent_types::ProviderSnapshot::default(),
20460            screen_state: gate4agent_types::PtyScreenState::default(),
20461        };
20462        let original = NodeSnapshot {
20463            node_id: NodeId::new("node-terminal-test").unwrap(),
20464            enabled_providers: vec![agent("kimi"), agent("codex")],
20465            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20466            workspaces: vec![WorkspaceSnapshot {
20467                workspace_id: WorkspaceId::new("primary").unwrap(),
20468                canonical_root: opaque_windows_path(
20469                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20470                ),
20471                sessions: vec![session],
20472                worktree_service_mode: None,
20473                managed_worktree_profiles: None,
20474            }],
20475            session_records: vec![bound_record],
20476            managed_worktrees: Vec::new(),
20477            launch_inventory: None,
20478            agent_progress: Vec::new(),
20479        };
20480
20481        let mut negotiated = original.clone();
20482        project_snapshot_history_for_wire(&mut negotiated, true);
20483        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20484        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20485        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20486        assert!(!serde_json::to_string(&negotiated)
20487            .unwrap()
20488            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20489
20490        let mut legacy = original;
20491        project_snapshot_history_for_wire(&mut legacy, false);
20492        clear_snapshot_context_packs(&mut legacy);
20493        assert_eq!(
20494            legacy.workspaces[0].sessions[0].history,
20495            gate4agent_types::HistorySnapshot::default(),
20496        );
20497        assert!(legacy.session_records[0].context_id.is_none());
20498        assert!(legacy.session_records[0].context.is_none());
20499        let encoded = serde_json::to_string(&legacy).unwrap();
20500        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20501        assert!(!encoded.contains("context-review"));
20502
20503        let mut reply = ResponseEnvelope {
20504            request_id: 7,
20505            result: Ok(NodeResponse::ContextPackExported { context }),
20506        };
20507        project_response_without_context_pack(&mut reply);
20508        assert_eq!(
20509            reply.result.unwrap_err().code,
20510            NodeFailureCode::UnsupportedCapability,
20511        );
20512    }
20513
20514    #[test]
20515    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20516        let shared = terminal_test_shared();
20517        let history = HistorySessionRecord {
20518            session_id: "fixture-history".to_owned(),
20519            title: None,
20520            cwd: None,
20521            model: None,
20522            message_count: 1,
20523            completed_turn_count: None,
20524            total_tokens: 0,
20525            messages: vec![gate4agent_types::HistoryMessageRecord {
20526                role: gate4agent_types::HistoryMessageRole::User,
20527                text: "bounded handoff".to_owned(),
20528            }],
20529        };
20530        let pack = NodeContextPack::export(
20531            ContextPackLineageReceipt {
20532                source_node_id: shared.node_id.clone(),
20533                source_session: terminal_address(1),
20534                source_provider: agent("claude"),
20535            },
20536            &history,
20537        )
20538        .unwrap();
20539        let context = shared
20540            .context_catalog
20541            .write()
20542            .unwrap_or_else(|poisoned| poisoned.into_inner())
20543            .insert(pack)
20544            .unwrap();
20545        let mut retained = record("codex", "context-retained-record");
20546        retained.context_id = Some(context.id.clone());
20547        retained.context = Some(context.clone());
20548        let record_id = retained.record_id.clone();
20549        shared.insert_record(retained).unwrap();
20550        assert_eq!(
20551            shared.forget_context_pack(&context.id).unwrap_err().code,
20552            NodeFailureCode::ContextPackBusy,
20553        );
20554        assert!(shared
20555            .context_catalog
20556            .read()
20557            .unwrap_or_else(|poisoned| poisoned.into_inner())
20558            .get(&context.id)
20559            .is_some());
20560        shared
20561            .session_records
20562            .lock()
20563            .unwrap_or_else(|poisoned| poisoned.into_inner())
20564            .records
20565            .remove(&record_id);
20566
20567        let mut spec = spawn_spec_fixture();
20568        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20569            value: context.id.clone(),
20570        };
20571        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20572        let receipt = resolved.receipt_with_materialization(
20573            shared.incarnation_id,
20574            terminal_address(2),
20575            None,
20576            None,
20577            Some(context.clone()),
20578        );
20579        assert!(receipt.context_binding_is_valid());
20580        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20581
20582        shared.forget_context_pack(&context.id).unwrap();
20583        assert!(shared
20584            .context_catalog
20585            .read()
20586            .unwrap_or_else(|poisoned| poisoned.into_inner())
20587            .get(&context.id)
20588            .is_none());
20589        assert!(shared
20590            .replay_spawn_spec(&spec, Instant::now())
20591            .unwrap()
20592            .is_none());
20593    }
20594
20595    #[test]
20596    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20597        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20598        let address = terminal_address(1);
20599        let selection = shared
20600            .select_environment_profile(
20601                address.session.instance_id,
20602                &agent("claude"),
20603                SessionMode::Pty,
20604                Some(&environment_profile),
20605            )
20606            .unwrap()
20607            .unwrap();
20608        shared.bind_session_with_policy(
20609            &address,
20610            ProviderRuntimePolicy::raw_pty(),
20611            Some(environment_profile.clone()),
20612        );
20613        selection.retain();
20614        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20615        assert!(matches!(
20616            runtime.remove_native_launch_profile(&native_id),
20617            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20618        ));
20619        assert!(shared.remove_binding(&address).is_some());
20620        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20621
20622        let mismatched = ResolvedEnvironmentProfileReceipt {
20623            profile_id: environment_profile.profile_id,
20624            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20625                "local-claude-r2",
20626            )
20627            .unwrap(),
20628            network_allowlist: None,
20629            browser_profile_id: None,
20630        };
20631        let error = match shared.select_environment_profile(
20632                AgentInstanceId(42),
20633                &agent("claude"),
20634                SessionMode::Pty,
20635                Some(&mismatched),
20636            ) {
20637            Ok(_) => panic!("mismatched environment profile revision was selected"),
20638            Err(error) => error,
20639        };
20640        assert_eq!(
20641            error.code,
20642            NodeFailureCode::EnvironmentProfileBindingMismatch,
20643        );
20644        assert!(!shared
20645            .session_bindings
20646            .lock()
20647            .unwrap_or_else(|poisoned| poisoned.into_inner())
20648            .contains_key(&AgentInstanceId(42)));
20649    }
20650
20651    #[test]
20652    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20653        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20654        let address = terminal_address(1);
20655        let selection = shared
20656            .select_environment_profile(
20657                address.session.instance_id,
20658                &agent("claude"),
20659                SessionMode::Pty,
20660                Some(&environment_profile),
20661            )
20662            .unwrap()
20663            .unwrap();
20664        let overlay = NativeInstanceLaunchOverlay::new(
20665            agent("claude"),
20666            TransportKind::Pty,
20667            vec![EnvMutation {
20668                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20669                value: Some(OsString::from("fixture-secret")),
20670            }],
20671            vec![
20672                OsString::from("--plugin-dir"),
20673                std::env::current_dir().unwrap().into_os_string(),
20674            ],
20675        )
20676        .unwrap();
20677        let instance_overlay = shared
20678            .install_prepared_launch_overlay(
20679                address.session.instance_id,
20680                PreparedNativeLaunchOverlay::Instance(overlay),
20681            )
20682            .unwrap()
20683            .unwrap();
20684        let bundle = ResolvedBundleReceipt {
20685            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20686            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20687            digest: crate::protocol::SpawnBundleDigest::new(format!(
20688                "sha256:{}",
20689                "0".repeat(64),
20690            ))
20691            .unwrap(),
20692        };
20693        shared.bind_session_with_materialization(
20694            &address,
20695            ProviderRuntimePolicy::raw_pty(),
20696            Some(environment_profile),
20697            Some(bundle),
20698            None,
20699            None,
20700        );
20701        selection.retain();
20702        instance_overlay.retain();
20703
20704        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20705        assert!(matches!(
20706            runtime.remove_native_launch_profile(&native_id),
20707            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20708        ));
20709        assert!(shared.remove_binding(&address).is_some());
20710        assert!(!shared
20711            .native_launch_profile_control
20712            .as_ref()
20713            .unwrap()
20714            .clear_native_instance_launch_overlay(address.session.instance_id));
20715        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20716    }
20717
20718    #[test]
20719    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20720        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20721        let address = SessionAddress {
20722            workspace_id: WorkspaceId::new("primary").unwrap(),
20723            session: SessionKey {
20724                instance_id: AgentInstanceId(201),
20725                generation: SessionGeneration(1),
20726            },
20727        };
20728        let error = match shared.prepare_session_materialization(
20729            &address,
20730            &agent("claude"),
20731            SessionMode::Pty,
20732            Some(&receipt),
20733            None,
20734            None,
20735            None,
20736        ) {
20737            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20738            Err(error) => error,
20739        };
20740        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20741        assert!(shared.handle.snapshot().sessions.is_empty());
20742        assert!(shared
20743            .session_bindings
20744            .lock()
20745            .unwrap_or_else(|poisoned| poisoned.into_inner())
20746            .is_empty());
20747        assert!(shared
20748            .materializations
20749            .lock()
20750            .unwrap_or_else(|poisoned| poisoned.into_inner())
20751            .is_empty());
20752        drop(shared);
20753        drop(runtime);
20754        std::fs::remove_dir_all(root).unwrap();
20755    }
20756
20757    #[test]
20758    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20759        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20760        let address = SessionAddress {
20761            workspace_id: WorkspaceId::new("primary").unwrap(),
20762            session: SessionKey {
20763                instance_id: AgentInstanceId(202),
20764                generation: SessionGeneration(1),
20765            },
20766        };
20767        let (overlay, materialization_guard) = shared
20768            .prepare_session_materialization(
20769                &address,
20770                &agent("claude"),
20771                SessionMode::Pty,
20772                Some(&receipt),
20773                None,
20774                None,
20775                None,
20776            )
20777            .unwrap()
20778            .unwrap();
20779        let materialization_id = materialization_guard.id().unwrap().clone();
20780        let materialization_root = shared
20781            .materializations
20782            .lock()
20783            .unwrap_or_else(|poisoned| poisoned.into_inner())
20784            .get(&materialization_id)
20785            .unwrap()
20786            .root()
20787            .to_path_buf();
20788        let selection = shared
20789            .select_environment_profile(
20790                address.session.instance_id,
20791                &agent("claude"),
20792                SessionMode::Pty,
20793                Some(&receipt),
20794            )
20795            .unwrap()
20796            .unwrap();
20797        assert!(shared
20798            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20799            .unwrap()
20800            .is_none());
20801        shared.bind_session_with_materialization(
20802            &address,
20803            ProviderRuntimePolicy::raw_pty(),
20804            Some(receipt),
20805            None,
20806            None,
20807            Some(materialization_id),
20808        );
20809        selection.retain();
20810        materialization_guard.retain();
20811
20812        assert!(materialization_root.is_dir());
20813        assert!(shared
20814            .session_bindings
20815            .lock()
20816            .unwrap_or_else(|poisoned| poisoned.into_inner())
20817            .contains_key(&address.session.instance_id));
20818
20819        let removed = shared.remove_binding(&address).unwrap();
20820        shared
20821            .cleanup_session_owned_materialization(&address, &removed)
20822            .unwrap();
20823        assert!(!materialization_root.exists());
20824        assert!(shared
20825            .materializations
20826            .lock()
20827            .unwrap_or_else(|poisoned| poisoned.into_inner())
20828            .is_empty());
20829        drop(shared);
20830        drop(runtime);
20831        std::fs::remove_dir_all(root).unwrap();
20832    }
20833
20834    #[tokio::test]
20835    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20836        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20837        let address = SessionAddress {
20838            workspace_id: WorkspaceId::new("primary").unwrap(),
20839            session: SessionKey {
20840                instance_id: AgentInstanceId(203),
20841                generation: SessionGeneration(1),
20842            },
20843        };
20844        let (overlay, materialization_guard) = shared
20845            .prepare_session_materialization(
20846                &address,
20847                &agent("claude"),
20848                SessionMode::Pty,
20849                Some(&receipt),
20850                None,
20851                None,
20852                None,
20853            )
20854            .unwrap()
20855            .unwrap();
20856        let materialization_id = materialization_guard.id().unwrap().clone();
20857        let materialization_root = shared
20858            .materializations
20859            .lock()
20860            .unwrap_or_else(|poisoned| poisoned.into_inner())
20861            .get(&materialization_id)
20862            .unwrap()
20863            .root()
20864            .to_path_buf();
20865        let selection = shared
20866            .select_environment_profile(
20867                address.session.instance_id,
20868                &agent("claude"),
20869                SessionMode::Pty,
20870                Some(&receipt),
20871            )
20872            .unwrap()
20873            .unwrap();
20874        assert!(shared
20875            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20876            .unwrap()
20877            .is_none());
20878        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20879        let record_id = shared
20880            .bind_spawn_session_with_materialization(
20881                &address,
20882                agent("claude"),
20883                SessionMode::Pty,
20884                policy,
20885                SpawnRecordPolicy::ProviderIdentityOnly,
20886                Some(receipt.clone()),
20887                None,
20888                None,
20889                Some(materialization_id.clone()),
20890            )
20891            .unwrap()
20892            .unwrap();
20893        selection.retain();
20894        materialization_guard.retain();
20895        assert_eq!(
20896            shared
20897                .materializations
20898                .lock()
20899                .unwrap_or_else(|poisoned| poisoned.into_inner())
20900                .get(&materialization_id)
20901                .unwrap()
20902                .owner(),
20903            &MaterializationOwner::Record {
20904                record_id: record_id.clone(),
20905            },
20906        );
20907
20908        let removed = shared.remove_binding(&address).unwrap();
20909        shared
20910            .cleanup_session_owned_materialization(&address, &removed)
20911            .unwrap();
20912        assert!(materialization_root.is_dir());
20913        assert!(shared
20914            .resolve_record_materialization(
20915                &record_id,
20916                &agent("claude"),
20917                SessionMode::Pty,
20918                Some(&receipt),
20919                None,
20920                None,
20921            )
20922            .unwrap()
20923            .is_some());
20924        {
20925            let mut records = shared
20926                .session_records
20927                .lock()
20928                .unwrap_or_else(|poisoned| poisoned.into_inner());
20929            let record = records.records.get_mut(&record_id).unwrap();
20930            record.active_session = None;
20931            record.state = ManagedSessionState::Unavailable;
20932        }
20933        shared.forget_session_record(&record_id).await.unwrap();
20934        assert!(!materialization_root.exists());
20935        assert!(shared
20936            .materializations
20937            .lock()
20938            .unwrap_or_else(|poisoned| poisoned.into_inner())
20939            .is_empty());
20940        drop(shared);
20941        drop(runtime);
20942        std::fs::remove_dir_all(root).unwrap();
20943    }
20944
20945    #[test]
20946    fn record_materialization_revalidation_failure_persists_recovery_required() {
20947        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20948        let state_path = root.join("node-state-v6.json");
20949        shared.state_path = Some(state_path.clone());
20950        let (_address, _record_id, materialization_id, materialization_root) =
20951            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20952        std::fs::write(
20953            materialization_root.join(".gate4agent-materialization-owner"),
20954            b"tampered",
20955        )
20956        .unwrap();
20957
20958        let error = match shared.resolve_record_materialization(
20959            &_record_id,
20960            &agent("claude"),
20961            SessionMode::Pty,
20962            Some(&receipt),
20963            None,
20964            None,
20965        ) {
20966            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20967            Err(error) => error,
20968        };
20969        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20970        assert_eq!(
20971            shared
20972                .materializations
20973                .lock()
20974                .unwrap_or_else(|poisoned| poisoned.into_inner())
20975                .get(&materialization_id)
20976                .unwrap()
20977                .state(),
20978            MaterializationState::RecoveryRequired,
20979        );
20980        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20981        assert_eq!(loaded.materializations.len(), 1);
20982        assert_eq!(
20983            loaded.materializations[0].state(),
20984            MaterializationState::RecoveryRequired,
20985        );
20986        drop(shared);
20987        drop(runtime);
20988        std::fs::remove_dir_all(root).unwrap();
20989    }
20990
20991    #[test]
20992    fn transient_record_materialization_resolution_failure_remains_ready() {
20993        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20994        let (_address, record_id, materialization_id, _materialization_root) =
20995            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20996        deny.store(true, Ordering::Release);
20997
20998        let error = match shared.resolve_record_materialization(
20999            &record_id,
21000            &agent("claude"),
21001            SessionMode::Pty,
21002            Some(&receipt),
21003            None,
21004            None,
21005        ) {
21006            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21007            Err(error) => error,
21008        };
21009        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21010        assert_eq!(
21011            shared
21012                .materializations
21013                .lock()
21014                .unwrap_or_else(|poisoned| poisoned.into_inner())
21015                .get(&materialization_id)
21016                .unwrap()
21017                .state(),
21018            MaterializationState::Ready,
21019        );
21020        drop(shared);
21021        drop(runtime);
21022        std::fs::remove_dir_all(root).unwrap();
21023    }
21024
21025    #[test]
21026    fn startup_missing_record_materialization_is_not_resumable() {
21027        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21028        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21029        shared
21030            .insert_record(ManagedSessionRecord {
21031                record_id: record_id.clone(),
21032                display_name: "missing materialization".to_owned(),
21033                provider: agent("claude"),
21034                mode: SessionMode::Pty,
21035                state: ManagedSessionState::Dormant,
21036                workspace_id: WorkspaceId::new("primary").unwrap(),
21037                canonical_root: opaque_windows_path(
21038                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21039                ),
21040                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21041                    key: gate4agent_types::ProviderSessionKey::SessionId,
21042                    id: "provider-session-missing-materialization".to_owned(),
21043                    transcript_path: None,
21044                }),
21045                active_session: None,
21046                environment_profile: Some(receipt),
21047                bundle: None,
21048                context_id: None,
21049                context: None,
21050                exported_context: None,
21051                task_binding: None,
21052                created_at_unix_ms: 1,
21053                updated_at_unix_ms: 1,
21054                last_error: None,
21055            })
21056            .unwrap();
21057
21058        shared.reconcile_materializations();
21059        let record = shared.record(&record_id).unwrap();
21060        assert_eq!(record.state, ManagedSessionState::Unavailable);
21061        assert!(record.provider_session.is_none());
21062        assert_eq!(
21063            record.last_error.as_deref(),
21064            Some("environment-profile-unavailable"),
21065        );
21066        drop(shared);
21067        drop(runtime);
21068        std::fs::remove_dir_all(root).unwrap();
21069    }
21070
21071    #[tokio::test]
21072    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21073        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21074        let lease = install_managed_lease(&shared);
21075        let address = SessionAddress {
21076            workspace_id: WorkspaceId::new("primary").unwrap(),
21077            session: SessionKey {
21078                instance_id: AgentInstanceId(204),
21079                generation: SessionGeneration(1),
21080            },
21081        };
21082        let (overlay, materialization_guard) = shared
21083            .prepare_session_materialization(
21084                &address,
21085                &agent("claude"),
21086                SessionMode::Pty,
21087                Some(&receipt),
21088                None,
21089                None,
21090                Some(lease.lease_id.clone()),
21091            )
21092            .unwrap()
21093            .unwrap();
21094        let materialization_id = materialization_guard.id().unwrap().clone();
21095        let materialization_root = shared
21096            .materializations
21097            .lock()
21098            .unwrap_or_else(|poisoned| poisoned.into_inner())
21099            .get(&materialization_id)
21100            .unwrap()
21101            .root()
21102            .to_path_buf();
21103        let selection = shared
21104            .select_environment_profile(
21105                address.session.instance_id,
21106                &agent("claude"),
21107                SessionMode::Pty,
21108                Some(&receipt),
21109            )
21110            .unwrap()
21111            .unwrap();
21112        assert!(shared
21113            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21114            .unwrap()
21115            .is_none());
21116        shared.bind_session_with_materialization(
21117            &address,
21118            ProviderRuntimePolicy::raw_pty(),
21119            Some(receipt),
21120            None,
21121            None,
21122            Some(materialization_id),
21123        );
21124        selection.retain();
21125        materialization_guard.retain();
21126
21127        let blocked = shared
21128            .cleanup_managed_worktree(&lease.lease_id, false)
21129            .await
21130            .unwrap_err();
21131        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21132        assert!(materialization_root.is_dir());
21133        let removed = shared.remove_binding(&address).unwrap();
21134        shared
21135            .cleanup_session_owned_materialization(&address, &removed)
21136            .unwrap();
21137        assert!(!materialization_root.exists());
21138        drop(shared);
21139        drop(runtime);
21140        std::fs::remove_dir_all(root).unwrap();
21141    }
21142
21143    #[tokio::test]
21144    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21145        let git_root = temporary_workspace_root("raw-managed-inventory");
21146        let source = git_root.join("source");
21147        let allocation = git_root.join("allocation");
21148        std::fs::create_dir_all(&source).unwrap();
21149        std::fs::create_dir_all(&allocation).unwrap();
21150        run_test_git(&source, &["init"]);
21151        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21152        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21153        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21154        run_test_git(&source, &["add", "seed.txt"]);
21155        run_test_git(&source, &["commit", "-m", "seed"]);
21156        let profile = ManagedWorktreeProfile::new(
21157            WorktreeProfileId::new("default").unwrap(),
21158            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21159            &allocation,
21160            "gate4agent",
21161            "HEAD",
21162            ManagedWorktreeRetention::RemoveWhenReleased,
21163        )
21164        .unwrap();
21165        let workspace = WorkspaceConfig::new(
21166            WorkspaceId::new("primary").unwrap(),
21167            &source,
21168        )
21169        .unwrap()
21170        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21171        .with_managed_worktree_profile(profile.clone())
21172        .unwrap();
21173        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21174            materialization_test_shared_with_workspace(false, workspace);
21175        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21176        let source_text = source.to_string_lossy().into_owned();
21177        let target_text = target.to_string_lossy().into_owned();
21178        let created = create_git_worktree(
21179            &source_text,
21180            &target_text,
21181            "gate4agent/mw-raw-inventory",
21182            Some("HEAD"),
21183        )
21184        .await
21185        .unwrap();
21186        let lease = ManagedWorktreeLeaseRecord {
21187            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21188            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21189            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21190            profile_id: profile.profile_id().clone(),
21191            profile_revision: profile.revision().clone(),
21192            target_root: created.path.clone(),
21193            branch: "gate4agent/mw-raw-inventory".to_owned(),
21194            base_commit: created.head.clone(),
21195            expected_head: Some(created.head.clone()),
21196            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21197            state: ManagedWorktreeLeaseState::Ready,
21198            session_holders: Vec::new(),
21199            record_holders: Vec::new(),
21200            cleanup_failure: None,
21201            created_at_unix_ms: 1,
21202            updated_at_unix_ms: 1,
21203        };
21204        shared
21205            .workspaces
21206            .write()
21207            .unwrap_or_else(|poisoned| poisoned.into_inner())
21208            .insert(lease.workspace_id.clone(), created.path.clone());
21209        *shared
21210            .managed_worktrees
21211            .lock()
21212            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21213            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21214        let address = SessionAddress {
21215            workspace_id: lease.workspace_id.clone(),
21216            session: SessionKey {
21217                instance_id: AgentInstanceId(205),
21218                generation: SessionGeneration(1),
21219            },
21220        };
21221        let (overlay, materialization_guard) = shared
21222            .prepare_session_materialization(
21223                &address,
21224                &agent("claude"),
21225                SessionMode::Pty,
21226                Some(&receipt),
21227                None,
21228                None,
21229                Some(lease.lease_id.clone()),
21230            )
21231            .unwrap()
21232            .unwrap();
21233        let materialization_id = materialization_guard.id().unwrap().clone();
21234        let materialization_root = shared
21235            .materializations
21236            .lock()
21237            .unwrap_or_else(|poisoned| poisoned.into_inner())
21238            .get(&materialization_id)
21239            .unwrap()
21240            .root()
21241            .to_path_buf();
21242        let selection = shared
21243            .select_environment_profile(
21244                address.session.instance_id,
21245                &agent("claude"),
21246                SessionMode::Pty,
21247                Some(&receipt),
21248            )
21249            .unwrap()
21250            .unwrap();
21251        assert!(shared
21252            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21253            .unwrap()
21254            .is_none());
21255        let record_id = shared
21256            .bind_spawn_session_with_materialization(
21257                &address,
21258                agent("claude"),
21259                SessionMode::Pty,
21260                ProviderRuntimePolicy::raw_pty(),
21261                SpawnRecordPolicy::Always,
21262                Some(receipt.clone()),
21263                None,
21264                None,
21265                Some(materialization_id.clone()),
21266            )
21267            .unwrap()
21268            .unwrap();
21269        selection.retain();
21270        materialization_guard.retain();
21271        let record = shared.record(&record_id).unwrap();
21272        assert_eq!(record.state, ManagedSessionState::Live);
21273        assert!(record.provider_session.is_none());
21274        assert!(record.environment_profile.is_none());
21275        assert!(record.bundle.is_none());
21276        assert!(record.context_id.is_none());
21277        assert!(record.context.is_none());
21278        assert_eq!(record.active_session.as_ref(), Some(&address));
21279        assert_eq!(
21280            shared
21281                .materializations
21282                .lock()
21283                .unwrap_or_else(|poisoned| poisoned.into_inner())
21284                .get(&materialization_id)
21285                .unwrap()
21286                .owner(),
21287            &MaterializationOwner::Session {
21288                incarnation_id: shared.incarnation_id,
21289                instance_id: address.session.instance_id,
21290                generation: address.session.generation,
21291            },
21292        );
21293        {
21294            let mut bindings = shared
21295                .session_bindings
21296                .lock()
21297                .unwrap_or_else(|poisoned| poisoned.into_inner());
21298            bindings
21299                .get_mut(&address.session.instance_id)
21300                .unwrap()
21301                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21302        }
21303        let lease_snapshot = shared
21304            .managed_worktrees
21305            .lock()
21306            .unwrap_or_else(|poisoned| poisoned.into_inner())
21307            .bind_session(
21308                &lease.lease_id,
21309                ManagedWorktreeSessionHolder {
21310                    incarnation_id: shared.incarnation_id,
21311                    instance_id: address.session.instance_id,
21312                    generation: address.session.generation,
21313                },
21314                None,
21315                unix_time_ms(),
21316            )
21317            .unwrap();
21318        assert_eq!(lease_snapshot.active_session_count, 1);
21319        assert_eq!(lease_snapshot.managed_record_count, 0);
21320        shared.persist_state().unwrap();
21321        assert!(shared
21322            .managed_worktrees
21323            .lock()
21324            .unwrap_or_else(|poisoned| poisoned.into_inner())
21325            .get(&lease.lease_id)
21326            .unwrap()
21327            .record_holders
21328            .is_empty());
21329        shared
21330            .handle
21331            .dispatch(shared.prepare_command(ControlCommand::Register {
21332                instance_id: address.session.instance_id,
21333                agent_id: agent("claude"),
21334                transport: TransportKind::Pty,
21335            }))
21336            .unwrap();
21337        runtime.tick().await;
21338        shared.reconcile_managed_record(
21339            &record_id,
21340            &address,
21341            &ControlEvent {
21342                sequence: 1,
21343                command_id: None,
21344                instance_id: address.session.instance_id,
21345                generation: address.session.generation,
21346                event: ControlEventKind::Removed,
21347            },
21348            false,
21349            false,
21350        );
21351        let remove = shared.remove_session(&address);
21352        let drive_runtime = async {
21353            runtime.tick().await;
21354        };
21355        let (removed, ()) = tokio::join!(remove, drive_runtime);
21356        removed.unwrap();
21357
21358        assert!(!materialization_root.exists());
21359        assert!(!Path::new(&created.path).exists());
21360        assert_eq!(
21361            shared
21362                .managed_worktrees
21363                .lock()
21364                .unwrap_or_else(|poisoned| poisoned.into_inner())
21365                .get(&lease.lease_id)
21366                .unwrap()
21367                .state,
21368            ManagedWorktreeLeaseState::Removed,
21369        );
21370        let renamed = shared
21371            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21372            .unwrap();
21373        assert_eq!(renamed.record_id, record_id);
21374        assert_eq!(renamed.display_name, "raw managed inventory");
21375        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21376        assert!(renamed.active_session.is_none());
21377
21378        drop(shared);
21379        drop(runtime);
21380        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21381        std::fs::remove_dir_all(git_root).unwrap();
21382    }
21383
21384    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21385        let root = std::env::current_dir().unwrap()
21386            .join("managed-test-target")
21387            .to_string_lossy()
21388            .into_owned();
21389        let lease = ManagedWorktreeLeaseRecord {
21390            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21391            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21392            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21393            profile_id: WorktreeProfileId::new("default").unwrap(),
21394            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21395            target_root: root.clone(),
21396            branch: "gate4agent/mw-test".to_owned(),
21397            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21398            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21399            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21400            state: ManagedWorktreeLeaseState::Ready,
21401            session_holders: Vec::new(),
21402            record_holders: Vec::new(),
21403            cleanup_failure: None,
21404            created_at_unix_ms: 1,
21405            updated_at_unix_ms: 1,
21406        };
21407        shared.workspaces.write()
21408            .unwrap_or_else(|poisoned| poisoned.into_inner())
21409            .insert(lease.workspace_id.clone(), root);
21410        *shared.managed_worktrees.lock()
21411            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21412            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21413        lease
21414    }
21415
21416    #[cfg(feature = "fixture")]
21417    #[test]
21418    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21419        let mut shared = terminal_test_shared();
21420        shared.fixture_semantic_hook_policy = true;
21421        let lease = install_managed_lease(&shared);
21422        let address = SessionAddress {
21423            workspace_id: lease.workspace_id.clone(),
21424            session: SessionKey {
21425                instance_id: AgentInstanceId(206),
21426                generation: SessionGeneration(1),
21427            },
21428        };
21429        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21430        let effective_runtime_policy =
21431            shared.effective_spawn_runtime_policy(pre_admission_policy);
21432        assert_ne!(effective_runtime_policy, pre_admission_policy);
21433        assert!(effective_runtime_policy.provider_session_identity);
21434
21435        let record_id = shared
21436            .bind_spawn_session(
21437                &address,
21438                agent("claude"),
21439                SessionMode::Pty,
21440                effective_runtime_policy,
21441                SpawnRecordPolicy::Always,
21442                None,
21443            )
21444            .unwrap()
21445            .unwrap();
21446        assert_eq!(
21447            shared.record(&record_id).unwrap().state,
21448            ManagedSessionState::IdentityPending,
21449        );
21450        let (record_holder, raw_inventory_record_id) = {
21451            let mut bindings = shared
21452                .session_bindings
21453                .lock()
21454                .unwrap_or_else(|poisoned| poisoned.into_inner());
21455            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21456            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21457            managed_spawn_record_ownership(binding, effective_runtime_policy)
21458        };
21459        assert_eq!(record_holder.as_ref(), Some(&record_id));
21460        assert!(raw_inventory_record_id.is_none());
21461        let snapshot = shared
21462            .managed_worktrees
21463            .lock()
21464            .unwrap_or_else(|poisoned| poisoned.into_inner())
21465            .bind_session(
21466                &lease.lease_id,
21467                ManagedWorktreeSessionHolder {
21468                    incarnation_id: shared.incarnation_id,
21469                    instance_id: address.session.instance_id,
21470                    generation: address.session.generation,
21471                },
21472                record_holder,
21473                unix_time_ms(),
21474            )
21475            .unwrap();
21476        assert_eq!(snapshot.managed_record_count, 1);
21477        assert_eq!(
21478            shared
21479                .managed_worktrees
21480                .lock()
21481                .unwrap_or_else(|poisoned| poisoned.into_inner())
21482                .get(&lease.lease_id)
21483                .unwrap()
21484                .record_holders,
21485            vec![record_id.clone()],
21486        );
21487
21488        let identity = ProviderSessionIdentity {
21489            key: ProviderSessionKey::SessionId,
21490            id: "monitoring-fixture-managed-session".to_owned(),
21491            transcript_path: None,
21492        };
21493        shared.publish_control(ControlEvent {
21494            sequence: 1,
21495            command_id: None,
21496            instance_id: address.session.instance_id,
21497            generation: address.session.generation,
21498            event: ControlEventKind::ProviderEvent {
21499                sequence: 1,
21500                source: gate4agent_types::ProviderSource {
21501                    family: AdapterFamily::Hook,
21502                    binding: AdapterBinding::new(
21503                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21504                        "fixture/v1",
21505                        gate4agent_types::AdapterVerification::SyntheticFixture,
21506                    )
21507                    .unwrap(),
21508                },
21509                source_sequence: 1,
21510                event: ProviderEvent::SessionIdentityObserved {
21511                    identity: identity.clone(),
21512                },
21513            },
21514        });
21515
21516        let live = shared.record(&record_id).unwrap();
21517        assert_eq!(live.state, ManagedSessionState::Live);
21518        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21519        assert_eq!(live.active_session.as_ref(), Some(&address));
21520        assert_eq!(
21521            shared
21522                .managed_worktrees
21523                .lock()
21524                .unwrap_or_else(|poisoned| poisoned.into_inner())
21525                .get(&lease.lease_id)
21526                .unwrap()
21527                .record_holders,
21528            vec![record_id],
21529        );
21530    }
21531
21532    fn managed_v2_test_shared(
21533        allocation_root: &Path,
21534        state_path: Option<PathBuf>,
21535        profile_revision: &str,
21536    ) -> NodeShared {
21537        let profile = ManagedWorktreeProfile::new(
21538            WorktreeProfileId::new("default").unwrap(),
21539            WorktreeProfileRevision::new(profile_revision).unwrap(),
21540            allocation_root,
21541            "gate4agent",
21542            "HEAD",
21543            ManagedWorktreeRetention::RemoveWhenReleased,
21544        )
21545        .unwrap();
21546        let workspace = WorkspaceConfig::new(
21547            WorkspaceId::new("primary").unwrap(),
21548            std::env::current_dir().unwrap(),
21549        )
21550        .unwrap()
21551        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21552        .with_managed_worktree_profile(profile)
21553        .unwrap();
21554        let catalog = active_registry().unwrap();
21555        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21556        let mut shared = NodeShared::new(
21557            handle,
21558            "fixture-token".to_owned(),
21559            NodeId::new("node-terminal-test").unwrap(),
21560            vec![workspace],
21561            vec![agent("claude")],
21562        );
21563        shared.state_path = state_path;
21564        shared
21565    }
21566
21567    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21568        let mut spawn_spec = spawn_spec_fixture();
21569        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21570        ManagedWorktreeSpawnRequestV2 {
21571            spawn_spec,
21572            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21573            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21574        }
21575    }
21576
21577    #[test]
21578    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21579        let shared = terminal_test_shared();
21580        let mut lease = install_managed_lease(&shared);
21581        let session = SessionAddress {
21582            workspace_id: lease.workspace_id.clone(),
21583            session: SessionKey {
21584                instance_id: AgentInstanceId(73),
21585                generation: SessionGeneration(4),
21586            },
21587        };
21588        lease.state = ManagedWorktreeLeaseState::InUse;
21589        lease.session_holders.push(ManagedWorktreeSessionHolder {
21590            incarnation_id: shared.incarnation_id,
21591            instance_id: session.session.instance_id,
21592            generation: session.session.generation,
21593        });
21594        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21595        assert!(resolved.target.worktree_id.is_none());
21596
21597        let receipt = managed_spawn_receipt(
21598            &resolved,
21599            shared.incarnation_id,
21600            session.clone(),
21601            lease.snapshot(),
21602            None,
21603            None,
21604            None,
21605        );
21606        assert!(resolved.target.worktree_id.is_none());
21607        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21608        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21609        assert_eq!(receipt.spawn.session, session);
21610        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21611        let encoded = serde_json::to_vec(&receipt).unwrap();
21612        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21613        assert_eq!(decoded, receipt);
21614    }
21615
21616    #[tokio::test]
21617    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21618        let root = temporary_workspace_root("managed-v2-revision");
21619        let allocation = root.join("allocation");
21620        std::fs::create_dir_all(&allocation).unwrap();
21621        let shared = managed_v2_test_shared(
21622            &allocation,
21623            Some(root.join("node-state.json")),
21624            "v1",
21625        );
21626        let mut request = managed_v2_request("managed-v2-revision");
21627        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21628
21629        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21630
21631        assert_eq!(
21632            failure.code,
21633            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21634        );
21635        assert!(shared
21636            .managed_worktrees
21637            .lock()
21638            .unwrap_or_else(|poisoned| poisoned.into_inner())
21639            .records()
21640            .is_empty());
21641        assert!(shared
21642            .managed_spawn_replays
21643            .lock()
21644            .unwrap_or_else(|poisoned| poisoned.into_inner())
21645            .is_empty());
21646        std::fs::remove_dir_all(root).unwrap();
21647    }
21648
21649    #[test]
21650    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21651        let root = temporary_workspace_root("managed-v2-concurrent");
21652        let allocation = root.join("allocation");
21653        std::fs::create_dir_all(&allocation).unwrap();
21654        let shared = Arc::new(managed_v2_test_shared(
21655            &allocation,
21656            Some(root.join("node-state.json")),
21657            "v1",
21658        ));
21659        let request = managed_v2_request("managed-v2-concurrent");
21660        let barrier = Arc::new(std::sync::Barrier::new(3));
21661        let mut joins = Vec::new();
21662        for _ in 0..2 {
21663            let shared = Arc::clone(&shared);
21664            let request = request.clone();
21665            let barrier = Arc::clone(&barrier);
21666            joins.push(std::thread::spawn(move || {
21667                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21668                barrier.wait();
21669                shared.acquire_durable_managed_spawn_v2(&request, digest)
21670            }));
21671        }
21672        barrier.wait();
21673        let results = joins
21674            .into_iter()
21675            .map(|join| join.join().unwrap())
21676            .collect::<Vec<_>>();
21677        assert_eq!(
21678            results
21679                .iter()
21680                .filter(|result| {
21681                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21682                })
21683                .count(),
21684            1,
21685        );
21686        assert_eq!(
21687            results
21688                .iter()
21689                .filter(|result| {
21690                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21691                })
21692                .count(),
21693            1,
21694        );
21695        assert_eq!(
21696            shared
21697                .managed_spawn_replays
21698                .lock()
21699                .unwrap_or_else(|poisoned| poisoned.into_inner())
21700                .len(),
21701            1,
21702        );
21703        assert!(shared
21704            .managed_worktrees
21705            .lock()
21706            .unwrap_or_else(|poisoned| poisoned.into_inner())
21707            .records()
21708            .is_empty());
21709        drop(shared);
21710        std::fs::remove_dir_all(root).unwrap();
21711    }
21712
21713    #[tokio::test]
21714    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21715        let root = temporary_workspace_root("managed-v2-linked-pending");
21716        let allocation = root.join("allocation");
21717        std::fs::create_dir_all(&allocation).unwrap();
21718        let state_path = root.join("node-state.json");
21719        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21720        let lease = install_managed_lease(&shared);
21721        let request = managed_v2_request("managed-v2-linked-pending");
21722        assert!(matches!(
21723            shared
21724                .acquire_durable_managed_spawn_v2(
21725                    &request,
21726                    managed_spawn_request_digest_v2(&request).unwrap(),
21727                )
21728                .unwrap(),
21729            DurableManagedSpawnReplayDecision::Reserved,
21730        ));
21731        {
21732            let _transaction = shared
21733                .state_transaction
21734                .lock()
21735                .unwrap_or_else(|poisoned| poisoned.into_inner());
21736            shared
21737                .link_durable_managed_spawn_v2_lease_locked(
21738                    &request.spawn_spec.idempotency_key,
21739                    &lease.lease_id,
21740                )
21741                .unwrap();
21742            shared.persist_state_locked().unwrap();
21743        }
21744        drop(shared);
21745
21746        let mut loaded = session_registry::load(
21747            Some(&state_path),
21748            &NodeId::new("node-terminal-test").unwrap(),
21749        )
21750        .unwrap();
21751        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21752        *reopened
21753            .managed_worktrees
21754            .lock()
21755            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21756            ManagedWorktreeRegistry::from_records(
21757                std::mem::take(&mut loaded.managed_worktrees),
21758                std::mem::take(&mut loaded.managed_worktree_tombstones),
21759            )
21760            .unwrap();
21761        *reopened
21762            .managed_spawn_replays
21763            .lock()
21764            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21765            .managed_spawn_replays
21766            .into_iter()
21767            .map(|record| (record.idempotency_key.clone(), record))
21768            .collect();
21769        let lease_count = reopened
21770            .managed_worktrees
21771            .lock()
21772            .unwrap_or_else(|poisoned| poisoned.into_inner())
21773            .records()
21774            .len();
21775
21776        let failure = reopened
21777            .spawn_managed_worktree_v2(request)
21778            .await
21779            .unwrap_err();
21780
21781        assert_eq!(
21782            failure.code,
21783            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21784        );
21785        assert_eq!(
21786            reopened
21787                .managed_worktrees
21788                .lock()
21789                .unwrap_or_else(|poisoned| poisoned.into_inner())
21790                .records()
21791                .len(),
21792            lease_count,
21793        );
21794        std::fs::remove_dir_all(root).unwrap();
21795    }
21796
21797    #[tokio::test]
21798    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21799        let root = temporary_workspace_root("managed-v2-reopen");
21800        let allocation = root.join("allocation");
21801        std::fs::create_dir_all(&allocation).unwrap();
21802        let state_path = root.join("node-state.json");
21803        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21804        let lease = install_managed_lease(&shared);
21805        let request = managed_v2_request("managed-v2-reopen");
21806        let session = SessionAddress {
21807            workspace_id: lease.workspace_id.clone(),
21808            session: SessionKey {
21809                instance_id: AgentInstanceId(91),
21810                generation: SessionGeneration(1),
21811            },
21812        };
21813        let snapshot = {
21814            let mut registry = shared
21815                .managed_worktrees
21816                .lock()
21817                .unwrap_or_else(|poisoned| poisoned.into_inner());
21818            let current = registry.get_mut(&lease.lease_id).unwrap();
21819            current.state = ManagedWorktreeLeaseState::InUse;
21820            current.session_holders.push(ManagedWorktreeSessionHolder {
21821                incarnation_id: shared.incarnation_id,
21822                instance_id: session.session.instance_id,
21823                generation: session.session.generation,
21824            });
21825            current.snapshot()
21826        };
21827        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21828        let receipt = managed_spawn_receipt(
21829            &resolved,
21830            shared.incarnation_id,
21831            session,
21832            snapshot,
21833            None,
21834            None,
21835            None,
21836        );
21837        assert!(matches!(
21838            shared
21839                .acquire_durable_managed_spawn_v2(
21840                    &request,
21841                    managed_spawn_request_digest_v2(&request).unwrap(),
21842                )
21843                .unwrap(),
21844            DurableManagedSpawnReplayDecision::Reserved,
21845        ));
21846        {
21847            let _transaction = shared
21848                .state_transaction
21849                .lock()
21850                .unwrap_or_else(|poisoned| poisoned.into_inner());
21851            shared
21852                .link_durable_managed_spawn_v2_lease_locked(
21853                    &request.spawn_spec.idempotency_key,
21854                    &lease.lease_id,
21855                )
21856                .unwrap();
21857            shared.persist_state_locked().unwrap();
21858        }
21859        shared
21860            .commit_durable_managed_spawn_v2(&request, receipt.clone())
21861            .unwrap();
21862        let legacy_same_key = ManagedWorktreeSpawnRequest {
21863            spawn_spec: request.spawn_spec.clone(),
21864            worktree_profile_id: request.worktree_profile_id.clone(),
21865        };
21866        shared.remember_managed_spawn_attempt(
21867            Some(&request.spawn_spec.idempotency_key),
21868            legacy_same_key.clone(),
21869            Ok(receipt.clone()),
21870            Instant::now(),
21871        );
21872        assert!(shared
21873            .replay_managed_spawn(&legacy_same_key, Instant::now())
21874            .unwrap()
21875            .is_none());
21876        drop(shared);
21877
21878        let mut loaded = session_registry::load(
21879            Some(&state_path),
21880            &NodeId::new("node-terminal-test").unwrap(),
21881        )
21882        .unwrap();
21883        assert_eq!(loaded.managed_spawn_replays.len(), 1);
21884        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21885        *reopened
21886            .managed_worktrees
21887            .lock()
21888            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21889            ManagedWorktreeRegistry::from_records(
21890                std::mem::take(&mut loaded.managed_worktrees),
21891                std::mem::take(&mut loaded.managed_worktree_tombstones),
21892            )
21893            .unwrap();
21894        *reopened
21895            .managed_spawn_replays
21896            .lock()
21897            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21898            .managed_spawn_replays
21899            .into_iter()
21900            .map(|record| (record.idempotency_key.clone(), record))
21901            .collect();
21902        let lease_count_before = reopened
21903            .managed_worktrees
21904            .lock()
21905            .unwrap_or_else(|poisoned| poisoned.into_inner())
21906            .records()
21907            .len();
21908
21909        assert_eq!(
21910            reopened
21911                .spawn_managed_worktree_v2(request.clone())
21912                .await
21913                .unwrap(),
21914            receipt,
21915        );
21916        assert_eq!(
21917            reopened
21918                .managed_worktrees
21919                .lock()
21920                .unwrap_or_else(|poisoned| poisoned.into_inner())
21921                .records()
21922                .len(),
21923            lease_count_before,
21924        );
21925        let mut changed = request;
21926        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21927        let failure = reopened
21928            .spawn_managed_worktree_v2(changed)
21929            .await
21930            .unwrap_err();
21931        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21932        assert_eq!(
21933            reopened
21934                .managed_worktrees
21935                .lock()
21936                .unwrap_or_else(|poisoned| poisoned.into_inner())
21937                .records()
21938                .len(),
21939            lease_count_before,
21940        );
21941        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21942        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21943        let failure = reopened
21944            .spawn_managed_worktree_v2(new_key)
21945            .await
21946            .unwrap_err();
21947        assert_eq!(
21948            failure.code,
21949            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21950        );
21951        assert_eq!(
21952            reopened
21953                .managed_worktrees
21954                .lock()
21955                .unwrap_or_else(|poisoned| poisoned.into_inner())
21956                .records()
21957                .len(),
21958            lease_count_before,
21959        );
21960        assert_eq!(
21961            reopened
21962                .managed_spawn_replays
21963                .lock()
21964                .unwrap_or_else(|poisoned| poisoned.into_inner())
21965                .len(),
21966            1,
21967        );
21968        std::fs::remove_dir_all(root).unwrap();
21969    }
21970
21971    #[test]
21972    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21973        let shared = terminal_test_shared();
21974        let request = ManagedWorktreeSpawnRequest {
21975            spawn_spec: spawn_spec_fixture(),
21976            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21977        };
21978        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21979        shared.remember_managed_spawn(
21980            request.clone(),
21981            Err(expected.clone()),
21982            Instant::now(),
21983        );
21984
21985        assert_eq!(
21986            shared
21987                .replay_managed_spawn(&request, Instant::now())
21988                .unwrap()
21989                .unwrap(),
21990            Err(expected),
21991        );
21992        assert!(shared
21993            .managed_spawn_replays
21994            .lock()
21995            .unwrap_or_else(|poisoned| poisoned.into_inner())
21996            .is_empty());
21997    }
21998
21999    #[tokio::test]
22000    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22001        let shared = terminal_test_shared();
22002        let lease = install_managed_lease(&shared);
22003        let raw = shared.spawn_session(
22004            lease.workspace_id.clone(),
22005            agent("claude"),
22006            SessionMode::Pty,
22007            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22008            None,
22009        ).await.unwrap_err();
22010        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22011        let mut spec = spawn_spec_fixture();
22012        spec.target.workspace_id = lease.workspace_id.clone();
22013        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22014        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22015        let mut selected = spawn_spec_fixture();
22016        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22017        selected.target.worktree_id = Some(lease.workspace_id);
22018        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22019        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22020        assert!(shared.handle.snapshot().sessions.is_empty());
22021    }
22022
22023    #[test]
22024    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22025        let shared = terminal_test_shared();
22026        let lease = install_managed_lease(&shared);
22027        let snapshot = snapshot_for_wire(
22028            &shared, true, true, true, false, true, true, false, false, true,
22029        );
22030        assert!(snapshot.managed_worktrees.is_empty());
22031        assert!(snapshot.workspaces.iter().all(|workspace| {
22032            workspace.managed_worktree_profiles.is_none()
22033        }));
22034        let mut reply = ResponseEnvelope {
22035            request_id: 1,
22036            result: Ok(NodeResponse::Resync {
22037                event_sequence: 1,
22038                oldest_available_sequence: 1,
22039                snapshot: shared.snapshot(),
22040                events: vec![NodeEventEnvelope {
22041                    sequence: 1,
22042                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22043                }],
22044            }),
22045        };
22046        project_response_without_managed_worktrees(&mut reply);
22047        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22048            panic!("projection changed the response kind");
22049        };
22050        assert!(snapshot.managed_worktrees.is_empty());
22051        assert!(events.is_empty());
22052        let mut git = git_snapshot_for_parser();
22053        git.branch = Some(lease.branch.clone());
22054        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22055            lease_id: lease.lease_id.clone(),
22056            source_workspace_id: lease.source_workspace_id.clone(),
22057            branch: lease.branch.clone(),
22058            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22059            active_session_count: 1,
22060            managed_record_count: 0,
22061        });
22062        let mut inspection_reply = ResponseEnvelope {
22063            request_id: 2,
22064            result: Ok(NodeResponse::WorkspaceInspected {
22065                inspection: WorkspaceInspection {
22066                    workspace_id: lease.workspace_id.clone(),
22067                    entries: Vec::new(),
22068                    tree_truncated: false,
22069                    git,
22070                    truncation: None,
22071                },
22072            }),
22073        };
22074        project_response_without_managed_worktrees(&mut inspection_reply);
22075        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22076            panic!("projection changed the inspection response kind");
22077        };
22078        assert!(inspection.git.managed_worktree.is_none());
22079        let failure = managed_git_worktree_failure(GitWorktreeError {
22080            kind: GitWorktreeErrorKind::Failed,
22081            message: r"git failed at C:\private\managed-target".to_owned(),
22082        });
22083        assert!(!failure.message.contains("private"));
22084        assert!(!failure.message.contains("managed-target"));
22085    }
22086
22087    #[tokio::test]
22088    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22089        let root = temporary_workspace_root("managed-restart");
22090        let source = root.join("source");
22091        let allocation = root.join("allocation");
22092        std::fs::create_dir_all(&source).unwrap();
22093        std::fs::create_dir_all(&allocation).unwrap();
22094        run_test_git(&source, &["init"]);
22095        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22096        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22097        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22098        run_test_git(&source, &["add", "seed.txt"]);
22099        run_test_git(&source, &["commit", "-m", "seed"]);
22100        let profile = ManagedWorktreeProfile::new(
22101            WorktreeProfileId::new("default").unwrap(),
22102            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22103            &allocation,
22104            "gate4agent",
22105            "HEAD",
22106            ManagedWorktreeRetention::Retain,
22107        ).unwrap();
22108        let workspace = WorkspaceConfig::new(
22109            WorkspaceId::new("primary").unwrap(),
22110            &source,
22111        ).unwrap()
22112            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22113            .with_managed_worktree_profile(profile.clone()).unwrap();
22114        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22115        let source_text = workspace.canonical_root().to_owned();
22116        let target_text = target.to_string_lossy().into_owned();
22117        let created = create_git_worktree(
22118            &source_text,
22119            &target_text,
22120            "gate4agent/mw-restart",
22121            Some("HEAD"),
22122        ).await.unwrap();
22123        let lease = ManagedWorktreeLeaseRecord {
22124            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22125            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22126            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22127            profile_id: profile.profile_id().clone(),
22128            profile_revision: profile.revision().clone(),
22129            target_root: created.path.clone(),
22130            branch: "gate4agent/mw-restart".to_owned(),
22131            base_commit: created.head.clone(),
22132            expected_head: None,
22133            retention: ManagedWorktreeRetention::Retain,
22134            state: ManagedWorktreeLeaseState::Allocating,
22135            session_holders: Vec::new(),
22136            record_holders: Vec::new(),
22137            cleanup_failure: None,
22138            created_at_unix_ms: 1,
22139            updated_at_unix_ms: 1,
22140        };
22141        let catalog = active_registry().unwrap();
22142        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22143        let shared = NodeShared::new_with_incarnation(
22144            handle,
22145            "fixture-token".to_owned(),
22146            NodeId::new("node-managed-restart").unwrap(),
22147            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22148            vec![workspace],
22149            vec![agent("claude")],
22150            ProviderRuntimeStatuses::default(),
22151            None,
22152            Vec::new(),
22153            Vec::new(),
22154            SpawnProfileRegistry::default(),
22155            None,
22156            None,
22157            None,
22158            None,
22159            Vec::new(),
22160            vec![lease.clone()],
22161            Vec::new(),
22162            Vec::new(),
22163            None,
22164            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22165        );
22166        shared.reconcile_managed_worktrees().await;
22167        let retained = shared.managed_worktrees.lock()
22168            .unwrap_or_else(|poisoned| poisoned.into_inner())
22169            .get(&lease.lease_id).unwrap().clone();
22170        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22171        assert!(shared.workspaces.read()
22172            .unwrap_or_else(|poisoned| poisoned.into_inner())
22173            .contains_key(&lease.workspace_id));
22174        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22175        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22176        assert!(!Path::new(&created.path).exists());
22177        std::fs::remove_dir_all(root).unwrap();
22178    }
22179
22180    fn run_test_git(root: &Path, arguments: &[&str]) {
22181        let output = std::process::Command::new("git")
22182            .arg("-C").arg(root).args(arguments).output().unwrap();
22183        assert!(
22184            output.status.success(),
22185            "git {:?} failed: {}",
22186            arguments,
22187            String::from_utf8_lossy(&output.stderr),
22188        );
22189    }
22190
22191    #[tokio::test]
22192    async fn spawn_spec_validation_fails_before_session_mutation() {
22193        let shared = terminal_test_shared();
22194        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22195            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22196        }));
22197        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22198            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22199        }));
22200        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22201            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22202        }));
22203
22204        let mut target_mismatch = spawn_spec_fixture();
22205        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22206        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22207        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22208
22209        let mut unknown_profile = spawn_spec_fixture();
22210        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22211        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22212        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22213        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22214
22215        let mut revision_mismatch = spawn_spec_fixture();
22216        revision_mismatch.idempotency_key =
22217            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22218        revision_mismatch.expected_profile_revision =
22219            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22220        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22221        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22222
22223        let mut unsupported_materializer = spawn_spec_fixture();
22224        unsupported_materializer.idempotency_key =
22225            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22226        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22227            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22228        };
22229        let failure = shared
22230            .spawn_from_spec(unsupported_materializer)
22231            .await
22232            .unwrap_err();
22233        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22234
22235        let failure = shared
22236            .spawn_session_with_deadline(
22237                WorkspaceId::new("primary").unwrap(),
22238                agent("claude"),
22239                SessionMode::Pty,
22240                gate4agent_types::TerminalSize {
22241                    rows: 24,
22242                    columns: 80,
22243                },
22244                None,
22245                None,
22246                None,
22247                None,
22248                None,
22249                None,
22250                SpawnRecordPolicy::ProviderIdentityOnly,
22251                Some(Instant::now()),
22252                &[],
22253                None,
22254                ApprovalLevel::default(),
22255                None,
22256                None,
22257            )
22258            .await
22259            .unwrap_err();
22260        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22261        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22262        assert!(shared.handle.snapshot().sessions.is_empty());
22263    }
22264
22265    #[test]
22266    fn worktree_selection_requires_its_separate_negotiated_capability() {
22267        let mut spec = spawn_spec_fixture();
22268        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22269        let request = NodeRequest::SpawnSpec { spec };
22270        let spawn_spec_capability =
22271            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22272        assert!(request_uses_unnegotiated_capability(
22273            &request,
22274            &[spawn_spec_capability.clone()],
22275        ));
22276        let worktree_capability =
22277            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22278        assert!(request_uses_unnegotiated_capability(
22279            &request,
22280            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22281        ));
22282        let profile_revision_capability =
22283            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22284        assert!(request_uses_unnegotiated_capability(
22285            &request,
22286            &[
22287                spawn_spec_capability.clone(),
22288                worktree_capability.clone(),
22289                profile_revision_capability.clone(),
22290            ],
22291        ));
22292        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22293        // `Inherit`, not `Clear` — the profile's own defaults might still
22294        // materialize a bundle or a context pack, so both capabilities stay
22295        // conservatively required until negotiated (see
22296        // `requires_session_bundle_materialization_capability` /
22297        // `requires_history_context_pack_capability`).
22298        let bundle_materialization_capability =
22299            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22300        assert!(request_uses_unnegotiated_capability(
22301            &request,
22302            &[
22303                spawn_spec_capability.clone(),
22304                worktree_capability.clone(),
22305                profile_revision_capability.clone(),
22306                bundle_materialization_capability.clone(),
22307            ],
22308        ));
22309        let history_context_pack_capability =
22310            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22311        assert!(!request_uses_unnegotiated_capability(
22312            &request,
22313            &[
22314                spawn_spec_capability,
22315                worktree_capability,
22316                profile_revision_capability,
22317                bundle_materialization_capability,
22318                history_context_pack_capability,
22319            ],
22320        ));
22321    }
22322
22323    #[tokio::test]
22324    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22325        let mut shared = terminal_test_shared();
22326        let spec = spawn_spec_fixture();
22327        let receipt = shared
22328            .resolve_spawn_spec(&spec)
22329            .unwrap()
22330            .receipt(shared.incarnation_id, terminal_address(1));
22331        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22332
22333        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22334        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22335        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22336
22337        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22338
22339        let mut revision_conflicting = spec.clone();
22340        revision_conflicting.expected_profile_revision =
22341            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22342        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22343        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22344
22345        let mut stale_cache_miss = spec.clone();
22346        stale_cache_miss.idempotency_key =
22347            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22348        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22349        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22350
22351        let mut conflicting = spec;
22352        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22353        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22354        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22355        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22356        assert!(shared.handle.snapshot().sessions.is_empty());
22357    }
22358
22359    #[tokio::test]
22360    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22361        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22362        let mut spec = spawn_spec_fixture();
22363        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22364        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22365        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22366
22367        let failure = shared
22368            .create_worktree(
22369                WorkspaceId::new("primary").unwrap(),
22370                WorkspaceId::new("new-worktree").unwrap(),
22371                "not-an-absolute-path".to_owned(),
22372                "invalid branch".to_owned(),
22373                None,
22374            )
22375            .await
22376            .unwrap_err();
22377        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22378
22379        let failure = shared
22380            .remove_worktree(
22381                WorkspaceId::new("primary").unwrap(),
22382                "not-an-absolute-path".to_owned(),
22383            )
22384            .await
22385            .unwrap_err();
22386        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22387        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22388        assert!(shared.handle.snapshot().sessions.is_empty());
22389    }
22390
22391    #[tokio::test]
22392    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22393        let root = temporary_workspace_root("manual-worktree-compensation");
22394        let source = root.join("source");
22395        let target = root.join("target");
22396        let invalid_state_parent = root.join("state-parent-is-a-file");
22397        let invalid_state_path = invalid_state_parent.join("state.json");
22398        std::fs::create_dir_all(&source).unwrap();
22399        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22400        run_test_git(&source, &["init"]);
22401        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22402        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22403        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22404        run_test_git(&source, &["add", "seed.txt"]);
22405        run_test_git(&source, &["commit", "-m", "seed"]);
22406
22407        let catalog = active_registry().unwrap();
22408        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22409        let workspace = WorkspaceConfig::new(
22410            WorkspaceId::new("primary").unwrap(),
22411            &source,
22412        ).unwrap();
22413        let mut shared = NodeShared::new(
22414            handle,
22415            "fixture-token".to_owned(),
22416            NodeId::new("node-worktree-compensation").unwrap(),
22417            vec![workspace],
22418            vec![agent("claude")],
22419        );
22420        shared.state_path = Some(invalid_state_path);
22421
22422        let error = shared.create_worktree(
22423            WorkspaceId::new("primary").unwrap(),
22424            WorkspaceId::new("compensated").unwrap(),
22425            target.to_string_lossy().into_owned(),
22426            "gate4agent/compensated".to_owned(),
22427            Some("HEAD".to_owned()),
22428        ).await.unwrap_err();
22429
22430        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22431        assert!(!target.exists());
22432        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22433            .iter()
22434            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22435        std::fs::remove_dir_all(root).unwrap();
22436    }
22437
22438    #[test]
22439    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22440        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22441            NativeGitWorktreeSnapshot {
22442                path: path.to_owned(),
22443                head: "0123456789abcdef".to_owned(),
22444                branch: Some("refs/heads/test".to_owned()),
22445                is_bare,
22446                is_main,
22447                locked: false,
22448                lock_reason: None,
22449                prunable: false,
22450                prunable_reason: None,
22451                workspace_id: None,
22452            }
22453        }
22454
22455        let source = r"C:\repo";
22456        let selected = r"C:\repo-worktree";
22457        let listed = vec![
22458            worktree(source, true, false),
22459            worktree(selected, false, false),
22460        ];
22461        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22462
22463        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22464        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22465
22466        let bare = vec![
22467            worktree(source, true, false),
22468            worktree(selected, false, true),
22469        ];
22470        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22471        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22472
22473        let foreign_failure =
22474            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22475        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22476    }
22477
22478    fn terminal_frame(sequence: u64) -> TerminalFrame {
22479        TerminalFrame {
22480            sequence,
22481            size: gate4agent_types::TerminalSize {
22482                rows: 24,
22483                columns: 80,
22484            },
22485            cursor_row: 2,
22486            cursor_column: 3,
22487            contents: format!("frame-{sequence}"),
22488            formatted: vec![1, 2, 3],
22489            scrollback_formatted: Vec::new(),
22490            alternate_screen: false,
22491            mouse_protocol_enabled: false,
22492            mouse_protocol_encoding: Default::default(),
22493            produced_at_unix_ms: 0,
22494            screen_state: gate4agent_types::PtyScreenState::default(),
22495            bracketed_paste: None,
22496        }
22497    }
22498
22499    #[test]
22500    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22501        let shared = terminal_test_shared();
22502        let mut receiver = shared.terminal_event_tx.subscribe();
22503        let original = terminal_address(1);
22504
22505        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22506        let first = receiver.try_recv().unwrap();
22507        assert_eq!(first.len(), 1);
22508        assert!(matches!(
22509            &first[0].event,
22510            NodeEvent::TerminalFrame { address, frame }
22511                if address == &original && frame.sequence == 7
22512        ));
22513
22514        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22515        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22516        assert!(matches!(
22517            receiver.try_recv(),
22518            Err(broadcast::error::TryRecvError::Empty)
22519        ));
22520
22521        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22522        let advanced = receiver.try_recv().unwrap();
22523        assert_eq!(advanced[0].sequence, 2);
22524
22525        let resumed = terminal_address(2);
22526        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22527        let reset = receiver.try_recv().unwrap();
22528        assert_eq!(reset[0].sequence, 3);
22529        assert!(matches!(
22530            &reset[0].event,
22531            NodeEvent::TerminalFrame { address, frame }
22532                if address == &resumed && frame.sequence == 1
22533        ));
22534        shared.clear_terminal_frame_watermark(&resumed);
22535        assert!(!shared
22536            .terminal_frame_watermarks
22537            .lock()
22538            .unwrap_or_else(|poisoned| poisoned.into_inner())
22539            .contains_key(&resumed.session.instance_id));
22540    }
22541
22542    #[test]
22543    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22544        let shared = terminal_test_shared();
22545        let mut receiver = shared.terminal_event_tx.subscribe();
22546        let address = terminal_address(1);
22547        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22548
22549        for sequence in 1..=count {
22550            shared.publish_terminal_frame_candidates(vec![(
22551                address.clone(),
22552                terminal_frame(sequence),
22553            )]);
22554        }
22555
22556        let history = shared
22557            .history
22558            .lock()
22559            .unwrap_or_else(|poisoned| poisoned.into_inner());
22560        assert!(history.events.is_empty());
22561        assert_eq!(history.last_sequence, count);
22562        assert_eq!(history.replay_floor_sequence, 1);
22563        drop(history);
22564        let NodeResponse::Resync {
22565            event_sequence,
22566            oldest_available_sequence,
22567            events,
22568            ..
22569        } = shared.resync(0) else {
22570            unreachable!("resync helper returned another response")
22571        };
22572        assert_eq!(event_sequence, count);
22573        assert_eq!(oldest_available_sequence, 1);
22574        assert!(events.is_empty());
22575        assert!(matches!(
22576            resync_required_event(&shared).event,
22577            NodeEvent::ResyncRequired {
22578                oldest_available_sequence: 1,
22579            }
22580        ));
22581        assert!(matches!(
22582            receiver.try_recv(),
22583            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22584        ));
22585        let latest = receiver.try_recv().unwrap();
22586        assert_eq!(latest.len(), 1);
22587        assert_eq!(latest[0].sequence, count);
22588    }
22589
22590    #[test]
22591    fn real_durable_eviction_advances_authoritative_replay_floor() {
22592        let shared = terminal_test_shared();
22593        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22594            shared.publish(NodeEvent::ControllerChanged { controller: None });
22595        }
22596
22597        let NodeResponse::Resync {
22598            event_sequence,
22599            oldest_available_sequence,
22600            events,
22601            ..
22602        } = shared.resync(0) else {
22603            unreachable!("resync helper returned another response")
22604        };
22605        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22606        assert_eq!(oldest_available_sequence, 2);
22607        assert_eq!(events.first().map(|event| event.sequence), Some(2));
22608        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22609        assert!(matches!(
22610            resync_required_event(&shared).event,
22611            NodeEvent::ResyncRequired {
22612                oldest_available_sequence: 2,
22613            }
22614        ));
22615    }
22616
22617    #[test]
22618    fn legacy_terminal_frame_connection_has_no_live_subscription() {
22619        let shared = terminal_test_shared();
22620        let without_capability = terminal_event_subscription(&shared, false);
22621        let with_capability = terminal_event_subscription(&shared, true);
22622        assert!(without_capability.is_none());
22623        assert!(with_capability.is_some());
22624        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22625            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22626        }));
22627    }
22628
22629    #[test]
22630    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22631        let mut pending = Vec::new();
22632        let mut event_sequence = 0_u64;
22633        for frame_sequence in 1..=4 {
22634            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22635                event_sequence += 1;
22636                let mut address = terminal_address(1);
22637                address.session.instance_id = AgentInstanceId(instance);
22638                queue_connection_event(
22639                    &mut pending,
22640                    NodeEventEnvelope {
22641                        sequence: event_sequence,
22642                        event: NodeEvent::TerminalFrame {
22643                            address,
22644                            frame: terminal_frame(frame_sequence),
22645                        },
22646                    },
22647                    0,
22648                );
22649            }
22650        }
22651        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22652        pending.sort_unstable_by_key(|event| event.sequence);
22653
22654        let mut uninterrupted = pending.clone();
22655        let first_burst = connection_event_burst_len(uninterrupted.len());
22656        uninterrupted.drain(..first_burst);
22657        let second_burst = connection_event_burst_len(uninterrupted.len());
22658        uninterrupted.drain(..second_burst);
22659        assert!(uninterrupted.is_empty());
22660
22661        let (frame_tx, mut frame_rx) =
22662            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22663        frame_tx
22664            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22665                request_id: 77,
22666                request: NodeRequest::Snapshot,
22667            })))
22668            .unwrap();
22669        let burst_len = connection_event_burst_len(pending.len());
22670        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22671        pending.drain(..burst_len);
22672        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22673        assert!(matches!(
22674            frame_rx.try_recv(),
22675            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22676                request_id: 77,
22677                request: NodeRequest::Snapshot,
22678            })))
22679        ));
22680    }
22681
22682    #[test]
22683    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22684        let mut pending = Vec::new();
22685        for sequence in [7_u64, 8] {
22686            let mut address = terminal_address(1);
22687            address.session.instance_id = AgentInstanceId(sequence);
22688            queue_connection_event(
22689                &mut pending,
22690                NodeEventEnvelope {
22691                    sequence,
22692                    event: NodeEvent::TerminalFrame {
22693                        address,
22694                        frame: terminal_frame(sequence),
22695                    },
22696                },
22697                0,
22698            );
22699        }
22700        let mut discard_through = 0;
22701        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22702        assert!(pending.is_empty());
22703
22704        let replayed = NodeEventEnvelope {
22705            sequence: 8,
22706            event: NodeEvent::TerminalFrame {
22707                address: terminal_address(1),
22708                frame: terminal_frame(8),
22709            },
22710        };
22711        let advanced = NodeEventEnvelope {
22712            sequence: 9,
22713            event: NodeEvent::TerminalFrame {
22714                address: terminal_address(2),
22715                frame: terminal_frame(1),
22716            },
22717        };
22718        queue_connection_event_batch(
22719            &mut pending,
22720            &[replayed, advanced.clone()],
22721            discard_through,
22722        );
22723        assert_eq!(pending, vec![advanced]);
22724    }
22725
22726    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22727        ManagedSessionRecord {
22728            record_id: SessionRecordId::new(record_id).unwrap(),
22729            display_name: format!("{provider} record"),
22730            provider: agent(provider),
22731            mode: SessionMode::Pty,
22732            state: ManagedSessionState::Unavailable,
22733            workspace_id: WorkspaceId::new("primary").unwrap(),
22734            canonical_root: opaque_windows_path(
22735                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22736            ),
22737            provider_session: None,
22738            active_session: None,
22739            environment_profile: None,
22740            bundle: None,
22741            context_id: None,
22742            context: None,
22743            exported_context: None,
22744            task_binding: None,
22745            created_at_unix_ms: 1,
22746            updated_at_unix_ms: 1,
22747            last_error: None,
22748        }
22749    }
22750
22751    fn retention_test_record(
22752        workspace_id: &str,
22753        record_id: &str,
22754        state: ManagedSessionState,
22755        updated_at_unix_ms: u64,
22756    ) -> ManagedSessionRecord {
22757        ManagedSessionRecord {
22758            record_id: SessionRecordId::new(record_id).unwrap(),
22759            display_name: format!("{record_id} display"),
22760            provider: agent("claude"),
22761            mode: SessionMode::Pty,
22762            state,
22763            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22764            canonical_root: opaque_windows_path(
22765                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22766            ),
22767            provider_session: None,
22768            active_session: None,
22769            environment_profile: None,
22770            bundle: None,
22771            context_id: None,
22772            context: None,
22773            exported_context: None,
22774            task_binding: None,
22775            created_at_unix_ms: updated_at_unix_ms,
22776            updated_at_unix_ms,
22777            last_error: None,
22778        }
22779    }
22780
22781    /// Two workspaces of three `Unavailable` records each, staggered by
22782    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22783    /// `workspace-a` (all as old as that workspace's oldest real record --
22784    /// if the sweep's state gate ever leaked, these would be the first
22785    /// thing it wrongly retired). Ages, oldest to newest:
22786    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
22787    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
22788    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22789        vec![
22790            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22791            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22792            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22793            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22794            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22795            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22796            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22797            retention_test_record(
22798                "workspace-a",
22799                "decoy-pending",
22800                ManagedSessionState::IdentityPending,
22801                now - 500_000,
22802            ),
22803            retention_test_record(
22804                "workspace-a",
22805                "decoy-dormant",
22806                ManagedSessionState::Dormant,
22807                now - 500_000,
22808            ),
22809        ]
22810    }
22811
22812    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22813        let root = temporary_workspace_root("session-record-retention");
22814        std::fs::create_dir_all(&root).unwrap();
22815        let catalog = active_registry().unwrap();
22816        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22817        let workspace_a =
22818            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22819        let workspace_b =
22820            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22821        let mut shared = NodeShared::new(
22822            handle,
22823            "fixture-token".to_owned(),
22824            NodeId::new("node-retention-test").unwrap(),
22825            vec![workspace_a, workspace_b],
22826            vec![agent("claude")],
22827        );
22828        shared.state_path = Some(root.join("node-state-v10.json"));
22829        let now = unix_time_ms();
22830        for record in retention_fixture_records(now) {
22831            shared.insert_record(record).unwrap();
22832        }
22833        shared
22834            .persist_state_locked()
22835            .expect("fixture-only, empty-history persist against a fresh temp state path");
22836        (shared, runtime, root, now)
22837    }
22838
22839    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22840        shared
22841            .history
22842            .lock()
22843            .unwrap_or_else(|poisoned| poisoned.into_inner())
22844            .events
22845            .iter()
22846            .filter_map(|envelope| match &envelope.event {
22847                NodeEvent::SessionRecordRemoved { record_id } => {
22848                    Some(record_id.as_str().to_owned())
22849                }
22850                _ => None,
22851            })
22852            .collect()
22853    }
22854
22855    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22856        shared
22857            .session_records
22858            .lock()
22859            .unwrap_or_else(|poisoned| poisoned.into_inner())
22860            .records
22861            .keys()
22862            .map(|record_id| record_id.as_str().to_owned())
22863            .collect()
22864    }
22865
22866    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22867        values.iter().map(|value| (*value).to_owned()).collect()
22868    }
22869
22870    #[tokio::test]
22871    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22872        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22873        shared.session_record_retention = SessionRecordRetentionConfig {
22874            age_ms: 0,
22875            keep_per_workspace: 1,
22876        };
22877        shared.retire_unavailable_session_records().await;
22878
22879        assert_eq!(
22880            retention_remaining_record_ids(&shared),
22881            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22882        );
22883        assert_eq!(
22884            retention_removed_record_ids(&shared),
22885            owned(&["a1", "a2", "b1", "b2"]),
22886        );
22887        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22888
22889        drop(shared);
22890        drop(runtime);
22891        std::fs::remove_dir_all(root).unwrap();
22892    }
22893
22894    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
22895    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
22896    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
22897    /// 200s old against a 250s threshold) even though it fails the keep-N
22898    /// test too, proving retirement needs BOTH, not either.
22899    #[tokio::test]
22900    async fn session_record_retention_both_set_retires_only_the_intersection() {
22901        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22902        shared.session_record_retention = SessionRecordRetentionConfig {
22903            age_ms: 250_000,
22904            keep_per_workspace: 1,
22905        };
22906        shared.retire_unavailable_session_records().await;
22907
22908        assert_eq!(
22909            retention_remaining_record_ids(&shared),
22910            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22911        );
22912        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22913        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22914
22915        drop(shared);
22916        drop(runtime);
22917        std::fs::remove_dir_all(root).unwrap();
22918    }
22919
22920    #[tokio::test]
22921    async fn session_record_retention_both_disabled_is_a_no_op() {
22922        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22923        shared.session_record_retention = SessionRecordRetentionConfig::default();
22924        shared.retire_unavailable_session_records().await;
22925
22926        assert_eq!(
22927            retention_remaining_record_ids(&shared),
22928            owned(&[
22929                "a1", "a2", "a3", "b1", "b2", "b3",
22930                "decoy-live", "decoy-pending", "decoy-dormant",
22931            ]),
22932        );
22933        assert!(retention_removed_record_ids(&shared).is_empty());
22934        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22935
22936        drop(shared);
22937        drop(runtime);
22938        std::fs::remove_dir_all(root).unwrap();
22939    }
22940
22941    #[test]
22942    fn session_record_context_export_selects_one_exact_history_identity() {
22943        let candidates = vec![
22944            HistoryCandidateSummary {
22945                id: "candidate-other".to_owned(),
22946                session_id_hint: "provider-other".to_owned(),
22947                modified_at_unix_ms: Some(1),
22948            },
22949            HistoryCandidateSummary {
22950                id: "candidate-exact".to_owned(),
22951                session_id_hint: "provider-exact".to_owned(),
22952                modified_at_unix_ms: Some(2),
22953            },
22954        ];
22955        assert_eq!(
22956            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22957            "candidate-exact",
22958        );
22959    }
22960
22961    #[test]
22962    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22963        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22964            id: id.to_owned(),
22965            session_id_hint: hint.to_owned(),
22966            modified_at_unix_ms: None,
22967        };
22968        let missing = select_history_candidate(
22969            &[candidate("candidate-other", "provider-other")],
22970            Some("provider-exact"),
22971        )
22972        .unwrap_err();
22973        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22974        let ambiguous = select_history_candidate(
22975            &[
22976                candidate("candidate-a", "provider-exact"),
22977                candidate("candidate-b", "provider-exact"),
22978            ],
22979            Some("provider-exact"),
22980        )
22981        .unwrap_err();
22982        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22983    }
22984
22985    /// The PTY-sidecar-observation shape (no fleet provider today, but
22986    /// capability-keyed rather than vendor-locked) never carries a verified
22987    /// `provider_session`; its record-bound export instead falls back to
22988    /// "exactly one discovered candidate" (see
22989    /// `export_context_pack_for_session_record_inner`). This is the same
22990    /// ambiguity refusal as the identity-filtered path, just without an
22991    /// identity to filter by.
22992    #[test]
22993    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22994        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22995            id: id.to_owned(),
22996            session_id_hint: hint.to_owned(),
22997            modified_at_unix_ms: None,
22998        };
22999        assert_eq!(
23000            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23001                .unwrap(),
23002            "candidate-sole",
23003        );
23004        let missing = select_history_candidate(&[], None).unwrap_err();
23005        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23006        let ambiguous = select_history_candidate(
23007            &[
23008                candidate("candidate-a", "sidecar-session-a"),
23009                candidate("candidate-b", "sidecar-session-b"),
23010            ],
23011            None,
23012        )
23013        .unwrap_err();
23014        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23015    }
23016
23017    #[test]
23018    fn session_record_context_export_revalidation_rejects_record_change() {
23019        let session = terminal_address(1);
23020        let identity = ProviderSessionIdentity {
23021            key: ProviderSessionKey::SessionId,
23022            id: "provider-exact".to_owned(),
23023            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23024        };
23025        let mut expected = record("claude", "record-context-export");
23026        expected.state = ManagedSessionState::Live;
23027        expected.provider_session = Some(identity.clone());
23028        expected.active_session = Some(session.clone());
23029        let binding = SessionBinding {
23030            workspace_id: session.workspace_id.clone(),
23031            generation: session.session.generation,
23032            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23033            pending_resume: None,
23034            record_id: Some(expected.record_id.clone()),
23035            managed_worktree_lease_id: None,
23036            environment_profile: None,
23037            bundle: None,
23038            context: None,
23039            materialization_id: None,
23040        };
23041        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23042        assert!(session_record_context_export_binding_is_exact(
23043            &expected,
23044            &expected,
23045            Some(&identity),
23046            &session,
23047            &binding,
23048            &expected.provider,
23049            &current_root,
23050            false,
23051        ));
23052        let mut changed = expected.clone();
23053        changed.updated_at_unix_ms += 1;
23054        assert!(!session_record_context_export_binding_is_exact(
23055            &expected,
23056            &changed,
23057            Some(&identity),
23058            &session,
23059            &binding,
23060            &expected.provider,
23061            &current_root,
23062            false,
23063        ));
23064        // A same-instant timestamp bump alone (state and active_session
23065        // otherwise unchanged) is not the clean-detachment transition
23066        // either, even with the reactive flag on: only the exact Live ->
23067        // Dormant + active_session cleared shape qualifies.
23068        assert!(!session_record_context_export_binding_is_exact(
23069            &expected,
23070            &changed,
23071            Some(&identity),
23072            &session,
23073            &binding,
23074            &expected.provider,
23075            &current_root,
23076            true,
23077        ));
23078        let mut detached = expected.clone();
23079        detached.state = ManagedSessionState::Dormant;
23080        detached.active_session = None;
23081        detached.updated_at_unix_ms += 1;
23082        assert!(!session_record_context_export_binding_is_exact(
23083            &expected,
23084            &detached,
23085            Some(&identity),
23086            &session,
23087            &binding,
23088            &expected.provider,
23089            &current_root,
23090            false,
23091        ));
23092        assert!(session_record_context_export_binding_is_exact(
23093            &expected,
23094            &detached,
23095            Some(&identity),
23096            &session,
23097            &binding,
23098            &expected.provider,
23099            &current_root,
23100            true,
23101        ));
23102    }
23103
23104    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23105    /// for a record whose provider never carries a verified identity (the
23106    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23107    /// declares one today): `identity` is `None` throughout, and the
23108    /// exactness check falls back to comparing `provider_session` itself,
23109    /// which must still be `None` on both sides.
23110    #[test]
23111    fn session_record_context_export_binding_is_exact_without_identity() {
23112        let session = terminal_address(1);
23113        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23114        expected.state = ManagedSessionState::Live;
23115        expected.active_session = Some(session.clone());
23116        let binding = SessionBinding {
23117            workspace_id: session.workspace_id.clone(),
23118            generation: session.session.generation,
23119            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23120            pending_resume: None,
23121            record_id: Some(expected.record_id.clone()),
23122            managed_worktree_lease_id: None,
23123            environment_profile: None,
23124            bundle: None,
23125            context: None,
23126            materialization_id: None,
23127        };
23128        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23129        assert!(session_record_context_export_binding_is_exact(
23130            &expected,
23131            &expected,
23132            None,
23133            &session,
23134            &binding,
23135            &expected.provider,
23136            &current_root,
23137            false,
23138        ));
23139        // A record that gained an identity between the initial lookup and
23140        // this recheck no longer matches the `None` this shape expects to
23141        // keep seeing -- treated the same as any other mid-export drift.
23142        let mut drifted = expected.clone();
23143        drifted.provider_session = Some(ProviderSessionIdentity {
23144            key: ProviderSessionKey::SessionId,
23145            id: "unexpected-identity".to_owned(),
23146            transcript_path: None,
23147        });
23148        assert!(!session_record_context_export_binding_is_exact(
23149            &expected,
23150            &drifted,
23151            None,
23152            &session,
23153            &binding,
23154            &expected.provider,
23155            &current_root,
23156            false,
23157        ));
23158    }
23159
23160    #[test]
23161    fn session_record_context_export_preflight_requires_live_usable_source() {
23162        let mut source = record("claude", "record-context-export-preflight");
23163        source.state = ManagedSessionState::Live;
23164        assert!(session_record_context_export_source_is_usable(
23165            &source,
23166            &SessionStatus::Running,
23167            false,
23168        ));
23169        assert!(session_record_context_export_source_is_usable(
23170            &source,
23171            &SessionStatus::Exited { exit_code: Some(0) },
23172            false,
23173        ));
23174
23175        source.state = ManagedSessionState::Dormant;
23176        assert!(!session_record_context_export_source_is_usable(
23177            &source,
23178            &SessionStatus::Running,
23179            false,
23180        ));
23181        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23182        // the only caller that may treat a Dormant, cleanly-exited record as
23183        // still usable — the on-demand path above never does.
23184        assert!(session_record_context_export_source_is_usable(
23185            &source,
23186            &SessionStatus::Exited { exit_code: Some(0) },
23187            true,
23188        ));
23189        assert!(!session_record_context_export_source_is_usable(
23190            &source,
23191            &SessionStatus::Exited { exit_code: Some(0) },
23192            false,
23193        ));
23194        source.state = ManagedSessionState::Live;
23195        assert!(!session_record_context_export_source_is_usable(
23196            &source,
23197            &SessionStatus::Starting,
23198            false,
23199        ));
23200    }
23201
23202    #[test]
23203    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23204        let catalog = active_registry().unwrap();
23205        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23206        let workspace = WorkspaceConfig::new(
23207            WorkspaceId::new("primary").unwrap(),
23208            std::env::current_dir().unwrap(),
23209        )
23210        .unwrap();
23211        let shared = NodeShared::new(
23212            handle,
23213            "fixture-token".to_owned(),
23214            NodeId::new("node-context-export-conflict").unwrap(),
23215            vec![workspace],
23216            vec![agent("claude")],
23217        );
23218        let history = HistorySessionRecord {
23219            session_id: "provider-exact".to_owned(),
23220            title: None,
23221            cwd: None,
23222            model: None,
23223            message_count: 1,
23224            completed_turn_count: None,
23225            total_tokens: 0,
23226            messages: vec![gate4agent_types::HistoryMessageRecord {
23227                role: gate4agent_types::HistoryMessageRole::User,
23228                text: "bounded context".to_owned(),
23229            }],
23230        };
23231        let pack = NodeContextPack::export(
23232            ContextPackLineageReceipt {
23233                source_node_id: shared.node_id.clone(),
23234                source_session: terminal_address(1),
23235                source_provider: agent("claude"),
23236            },
23237            &history,
23238        )
23239        .unwrap();
23240        let context_id = pack.receipt().id.clone();
23241        let session = terminal_address(1);
23242        let identity = ProviderSessionIdentity {
23243            key: ProviderSessionKey::SessionId,
23244            id: history.session_id.clone(),
23245            transcript_path: None,
23246        };
23247        let mut expected = record("claude", "record-context-export-final-conflict");
23248        expected.state = ManagedSessionState::Live;
23249        expected.provider_session = Some(identity.clone());
23250        expected.active_session = Some(session.clone());
23251        let mut changed = expected.clone();
23252        changed.updated_at_unix_ms += 1;
23253        shared.insert_record(changed).unwrap();
23254
23255        let error = shared
23256            .commit_context_pack_for_session_record(
23257                &expected,
23258                Some(&identity),
23259                &session,
23260                "candidate-exact",
23261                &history,
23262                pack,
23263                false,
23264            )
23265            .unwrap_err();
23266
23267        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23268        assert!(shared
23269            .context_catalog
23270            .read()
23271            .unwrap_or_else(|poisoned| poisoned.into_inner())
23272            .get(&context_id)
23273            .is_none());
23274    }
23275
23276    #[tokio::test]
23277    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23278        let root = temporary_workspace_root("session-task-binding");
23279        std::fs::create_dir_all(&root).unwrap();
23280        let state_path = root.join("node-state.json");
23281        let catalog = active_registry().unwrap();
23282        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23283        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23284        let node_id = NodeId::new("node-1").unwrap();
23285        let mut shared = NodeShared::new(
23286            handle,
23287            "fixture-token".to_owned(),
23288            node_id.clone(),
23289            vec![workspace],
23290            vec![agent("claude")],
23291        );
23292        shared.state_path = Some(state_path.clone());
23293        let record = record("claude", "sr-task");
23294        let record_id = record.record_id.clone();
23295        shared.insert_record(record).unwrap();
23296        shared.persist_state().unwrap();
23297        shared
23298            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23299            .unwrap();
23300
23301        let minted = process_request_inner(
23302            &shared,
23303            77,
23304            ClientRole::Operator,
23305            NodeRequest::SetSessionTask {
23306                record_id: record_id.clone(),
23307                expected_revision: 0,
23308                target: SessionTaskTargetV1::New,
23309            },
23310        )
23311        .await
23312        .unwrap();
23313        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23314            panic!("session task mutation returned another response");
23315        };
23316        let minted_binding = minted.task_binding.clone().unwrap();
23317        let minted_task_id = minted_binding.task_id.clone().unwrap();
23318        assert_eq!(minted_binding.revision, 1);
23319        assert!(minted_task_id.as_str().starts_with("task-"));
23320        assert_eq!(minted_task_id.as_str().len(), 29);
23321
23322        let event_count = shared.current_sequence();
23323        let repeated = process_request_inner(
23324            &shared,
23325            77,
23326            ClientRole::Operator,
23327            NodeRequest::SetSessionTask {
23328                record_id: record_id.clone(),
23329                expected_revision: 1,
23330                target: SessionTaskTargetV1::Existing {
23331                    task_id: minted_task_id.clone(),
23332                },
23333            },
23334        )
23335        .await
23336        .unwrap();
23337        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23338        assert_eq!(shared.current_sequence(), event_count);
23339
23340        let stale = process_request_inner(
23341            &shared,
23342            77,
23343            ClientRole::Operator,
23344            NodeRequest::SetSessionTask {
23345                record_id: record_id.clone(),
23346                expected_revision: 0,
23347                target: SessionTaskTargetV1::Existing {
23348                    task_id: minted_task_id,
23349                },
23350            },
23351        )
23352        .await
23353        .unwrap_err();
23354        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23355
23356        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23357        let assigned = process_request_inner(
23358            &shared,
23359            77,
23360            ClientRole::Operator,
23361            NodeRequest::SetSessionTask {
23362                record_id: record_id.clone(),
23363                expected_revision: 1,
23364                target: SessionTaskTargetV1::Existing {
23365                    task_id: assigned_id.clone(),
23366                },
23367            },
23368        )
23369        .await
23370        .unwrap();
23371        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23372            panic!("session task assignment returned another response");
23373        };
23374        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23375        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23376
23377        let cleared = process_request_inner(
23378            &shared,
23379            77,
23380            ClientRole::Operator,
23381            NodeRequest::SetSessionTask {
23382                record_id: record_id.clone(),
23383                expected_revision: 2,
23384                target: SessionTaskTargetV1::Clear,
23385            },
23386        )
23387        .await
23388        .unwrap();
23389        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23390            panic!("session task clear returned another response");
23391        };
23392        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23393        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23394        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23395        assert_eq!(
23396            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23397            cleared.updated_at_unix_ms,
23398        );
23399
23400        let persisted = std::fs::read(&state_path).unwrap();
23401        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23402        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23403        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23404        std::fs::remove_dir_all(root).unwrap();
23405    }
23406
23407    #[test]
23408    fn session_task_capability_strips_snapshots_events_and_replies() {
23409        let mut bound = record("claude", "sr-task-projection");
23410        bound.updated_at_unix_ms = 2;
23411        bound.task_binding = Some(SessionTaskBindingV1 {
23412            revision: 1,
23413            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23414            changed_at_unix_ms: 2,
23415        });
23416        let request = NodeRequest::SetSessionTask {
23417            record_id: bound.record_id.clone(),
23418            expected_revision: 1,
23419            target: SessionTaskTargetV1::Clear,
23420        };
23421        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23422            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23423        }));
23424        assert!(!request_uses_unnegotiated_capability(
23425            &request,
23426            &baseline_capabilities().unwrap(),
23427        ));
23428        assert!(request_uses_unnegotiated_capability(&request, &[]));
23429
23430        let mut snapshot = terminal_test_shared().snapshot();
23431        snapshot.session_records.push(bound.clone());
23432        let mut reply = ResponseEnvelope {
23433            request_id: 1,
23434            result: Ok(NodeResponse::Resync {
23435                event_sequence: 1,
23436                oldest_available_sequence: 1,
23437                snapshot,
23438                events: vec![NodeEventEnvelope {
23439                    sequence: 1,
23440                    event: NodeEvent::SessionRecordUpserted {
23441                        record: bound.clone(),
23442                    },
23443                }],
23444            }),
23445        };
23446        project_response_without_session_task_binding(&mut reply);
23447        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23448            panic!("task projection changed the response kind");
23449        };
23450        assert!(snapshot.session_records[0].task_binding.is_none());
23451        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23452            panic!("task projection changed the event kind");
23453        };
23454        assert!(record.task_binding.is_none());
23455
23456        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23457            sequence: 2,
23458            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23459        });
23460        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23461            panic!("task event projection changed the event kind");
23462        };
23463        assert!(record.task_binding.is_none());
23464
23465        let mut direct = ResponseEnvelope {
23466            request_id: 2,
23467            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23468        };
23469        project_response_without_session_task_binding(&mut direct);
23470        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23471            panic!("task reply projection changed the response kind");
23472        };
23473        assert!(record.task_binding.is_none());
23474    }
23475
23476    #[test]
23477    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23478        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23479        let workspace = WorkspaceConfig::new(
23480            WorkspaceId::new("primary").unwrap(),
23481            std::env::current_dir().unwrap(),
23482        )
23483        .unwrap();
23484        let config = NodeServerConfig::new(
23485            DEFAULT_NODE_ENDPOINT,
23486            "fixture-token",
23487            NodeId::new("node-1").unwrap(),
23488            [workspace],
23489        )
23490        .unwrap();
23491        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23492    }
23493
23494    #[cfg(all(feature = "fixture", windows))]
23495    #[test]
23496    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23497        let root = temporary_workspace_root("provider-bundle-argv-hold");
23498        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23499        std::fs::create_dir_all(&root).unwrap();
23500        std::fs::create_dir_all(&outside).unwrap();
23501        let proof_path = root.join("provider.proof");
23502        let release_signal = root.join("release.signal");
23503        let config = NodeServerConfig::new(
23504            format!(
23505                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23506                std::process::id(),
23507            ),
23508            "fixture-token",
23509            NodeId::new("provider-bundle-hold-node").unwrap(),
23510            [WorkspaceConfig::new(
23511                WorkspaceId::new("primary").unwrap(),
23512                std::env::current_dir().unwrap(),
23513            )
23514            .unwrap()],
23515        )
23516        .unwrap()
23517        .with_spawn_profiles(SpawnProfileRegistry::new([
23518            crate::protocol::SpawnProfileDefaults {
23519                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23520                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23521                    .unwrap(),
23522                provider: agent("kimi"),
23523                mode: SessionMode::Pty,
23524                terminal_size: gate4agent_types::TerminalSize {
23525                    rows: 24,
23526                    columns: 80,
23527                },
23528                prompt: None,
23529                bundle_id: None,
23530                context_id: None,
23531                environment_profile_id: None,
23532            },
23533        ])
23534        .unwrap());
23535        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23536            config,
23537            agent("kimi"),
23538            proof_path.clone(),
23539            release_signal.clone(),
23540        )
23541        .unwrap();
23542        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23543
23544        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23545            agent("kimi"),
23546            proof_path.clone(),
23547            Some(release_signal.clone()),
23548        )
23549        .unwrap();
23550        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23551        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23552        let release_wait = wrapper
23553            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23554            .unwrap();
23555        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23556        assert!(proof_write < release_wait);
23557        assert!(release_wait < fixture_ready);
23558        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23559        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23560        assert_eq!(
23561            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23562            &[
23563                proof_path.to_string_lossy().into_owned(),
23564                release_signal.to_string_lossy().into_owned(),
23565            ],
23566        );
23567
23568        let error = NodeServer::provider_bundle_argv_fixture_spec(
23569            agent("kimi"),
23570            proof_path,
23571            Some(outside.join("release.signal")),
23572        )
23573        .unwrap_err();
23574        assert!(matches!(
23575            error,
23576            NodeServerError::Registry(message)
23577                if message == "provider bundle argv release path must share the proof parent"
23578        ));
23579        drop(server);
23580        std::fs::remove_dir_all(root).unwrap();
23581        std::fs::remove_dir_all(outside).unwrap();
23582    }
23583
23584    #[cfg(feature = "fixture")]
23585    #[test]
23586    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23587        let root = temporary_workspace_root("context-fixture-registry");
23588        std::fs::create_dir_all(&root).unwrap();
23589        let proof_path = root.join("context.proof");
23590        let config = NodeServerConfig::new(
23591            format!(
23592                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23593                std::process::id(),
23594            ),
23595            "fixture-token",
23596            NodeId::new("context-fixture-node").unwrap(),
23597            [WorkspaceConfig::new(
23598                WorkspaceId::new("primary").unwrap(),
23599                std::env::current_dir().unwrap(),
23600            )
23601            .unwrap()],
23602        )
23603        .unwrap();
23604        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23605        let mut enabled = server
23606            .shared
23607            .enabled_providers
23608            .iter()
23609            .map(AgentId::as_str)
23610            .collect::<Vec<_>>();
23611        enabled.sort_unstable();
23612        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23613        for provider in &server.shared.enabled_providers {
23614            assert!(server
23615                .shared
23616                .provider_adapter_contracts
23617                .iter()
23618                .any(|contract| {
23619                    &contract.provider == provider
23620                        && contract.family == AdapterFamily::History
23621                }));
23622        }
23623        drop(server);
23624        std::fs::remove_dir_all(root).unwrap();
23625    }
23626
23627    #[cfg(feature = "fixture")]
23628    #[test]
23629    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23630        let root = temporary_workspace_root("monitoring-context-fixture");
23631        std::fs::create_dir_all(&root).unwrap();
23632        let proof_path = root.join("context.proof");
23633        let standard = NodeServer::context_pack_fixture_catalog(
23634            proof_path.clone(),
23635            None,
23636            false,
23637        ).unwrap();
23638        let monitoring = NodeServer::context_pack_fixture_catalog(
23639            proof_path.clone(),
23640            None,
23641            true,
23642        ).unwrap();
23643        let claude = AgentId::new("claude").unwrap();
23644        let standard_claude = standard.get(&claude).unwrap();
23645        let monitoring_claude = monitoring.get(&claude).unwrap();
23646        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23647        // standard nor the monitoring fixture declares a hook adapter
23648        // anymore.
23649        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23650        assert_eq!(
23651            monitoring_claude.capabilities.adapters.history,
23652            standard_claude.capabilities.adapters.history,
23653        );
23654        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23655        let codex = AgentId::new("codex").unwrap();
23656        let standard_codex = standard.get(&codex).unwrap();
23657        let monitoring_codex = monitoring.get(&codex).unwrap();
23658        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23659        assert_eq!(
23660            monitoring_codex.capabilities.adapters.history,
23661            standard_codex.capabilities.adapters.history,
23662        );
23663        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23664        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23665            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23666            .unwrap();
23667        for required in [
23668            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23669            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23670            "@('role', 'text')",
23671            "g4a-private-provider-session-canary",
23672            "private-provider-login-identity",
23673            "$rawProof",
23674            "$env:GATE4AGENT_HOOK_URL",
23675            "g4a-private-provider-session-canary",
23676        ] {
23677            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23678        }
23679        for provider in ["grok", "kimi"] {
23680            let provider = AgentId::new(provider).unwrap();
23681            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23682        }
23683
23684        let config = NodeServerConfig::new(
23685            format!(
23686                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23687                std::process::id(),
23688            ),
23689            "fixture-token",
23690            NodeId::new("monitoring-context-fixture-node").unwrap(),
23691            [WorkspaceConfig::new(
23692                WorkspaceId::new("primary").unwrap(),
23693                std::env::current_dir().unwrap(),
23694            )
23695            .unwrap()],
23696        )
23697        .unwrap();
23698        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23699        assert!(server.shared.fixture_semantic_hook_policy);
23700        drop(server);
23701        std::fs::remove_dir_all(root).unwrap();
23702    }
23703
23704    #[cfg(feature = "fixture")]
23705    #[test]
23706    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23707        let root = temporary_workspace_root("kimi-context-only-fixture");
23708        std::fs::create_dir_all(&root).unwrap();
23709        let proof_path = root.join("context.proof");
23710        let standard = NodeServer::context_pack_fixture_catalog(
23711            proof_path.clone(),
23712            None,
23713            false,
23714        ).unwrap();
23715        let kimi = AgentId::new("kimi").unwrap();
23716        let context_only = NodeServer::context_pack_fixture_catalog(
23717            proof_path,
23718            Some(&kimi),
23719            false,
23720        )
23721        .unwrap();
23722
23723        let codex = AgentId::new("codex").unwrap();
23724        assert_eq!(
23725            standard.get(&codex).unwrap().launch.fixed_args,
23726            context_only.get(&codex).unwrap().launch.fixed_args,
23727        );
23728        let script = context_only
23729            .get(&kimi)
23730            .unwrap()
23731            .launch
23732            .fixed_args
23733            .iter()
23734            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23735            .expect("Kimi context-only validation script is missing");
23736        for required in [
23737            "$bundleArgs.Count -ne 0",
23738            "GATE4AGENT_CONTEXT_ROOT",
23739            "context-pack.json",
23740            "$entries.Count -ne 1",
23741            "g4a-context-pack-v1",
23742            "$document.PSObject.Properties['cwd']",
23743            "'claude', 'codex', 'grok', 'kimi'",
23744            "$hasUser",
23745            "$hasAssistant",
23746            "SHA256",
23747            "context-only",
23748        ] {
23749            assert!(script.contains(required), "missing validation: {required}");
23750        }
23751        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23752            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23753        }
23754        assert!(!standard
23755            .get(&kimi)
23756            .unwrap()
23757            .launch
23758            .fixed_args
23759            .iter()
23760            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23761
23762        std::fs::remove_dir_all(root).unwrap();
23763    }
23764
23765    #[cfg(all(feature = "fixture", windows))]
23766    #[tokio::test]
23767    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23768        let root = temporary_workspace_root("kimi-context-only-spawn");
23769        let workspace_root = root.join("workspace");
23770        std::fs::create_dir_all(&workspace_root).unwrap();
23771        let materialization_root = root.join("private-materializations");
23772        let proof_path = root.join("context.proof");
23773        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23774        let workspace_id = WorkspaceId::new("primary").unwrap();
23775        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23776        let endpoint = format!(
23777            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23778            std::process::id(),
23779            unix_time_ms(),
23780        );
23781        let spawn_profiles = SpawnProfileRegistry::new([
23782            crate::protocol::SpawnProfileDefaults {
23783                profile_id: profile_id.clone(),
23784                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23785                provider: agent("kimi"),
23786                mode: SessionMode::Pty,
23787                terminal_size: gate4agent_types::TerminalSize {
23788                    rows: 30,
23789                    columns: 120,
23790                },
23791                prompt: None,
23792                bundle_id: None,
23793                context_id: None,
23794                environment_profile_id: None,
23795            },
23796        ])
23797        .unwrap();
23798        let mut config = NodeServerConfig::new(
23799            &endpoint,
23800            "fixture-token",
23801            node_id.clone(),
23802            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23803        )
23804        .unwrap()
23805        .with_spawn_profiles(spawn_profiles)
23806        .with_session_environment_materialization(
23807            materialization_root,
23808            Arc::new(FixtureSecretResolver {
23809                deny: Arc::new(AtomicBool::new(false)),
23810            }),
23811        )
23812        .unwrap();
23813        config.fixture_raw_pty_runtime = true;
23814        let server = NodeServer::new_context_only_proof_fixture(
23815            config,
23816            agent("kimi"),
23817            proof_path.clone(),
23818        )
23819        .unwrap();
23820        let shared = Arc::clone(&server.shared);
23821        let shutdown = server.shutdown_handle();
23822
23823        let history = HistorySessionRecord {
23824            session_id: "context-only-source-history".to_owned(),
23825            title: None,
23826            cwd: None,
23827            model: None,
23828            message_count: 2,
23829            completed_turn_count: None,
23830            total_tokens: 0,
23831            messages: vec![
23832                gate4agent_types::HistoryMessageRecord {
23833                    role: gate4agent_types::HistoryMessageRole::User,
23834                    text: "continue the bounded parent run through native C2".to_owned(),
23835                },
23836                gate4agent_types::HistoryMessageRecord {
23837                    role: gate4agent_types::HistoryMessageRole::Assistant,
23838                    text: "the exact parent context is ready for the child run".to_owned(),
23839                },
23840            ],
23841        };
23842        let pack = NodeContextPack::export(
23843            ContextPackLineageReceipt {
23844                source_node_id: node_id.clone(),
23845                source_session: SessionAddress {
23846                    workspace_id: workspace_id.clone(),
23847                    session: SessionKey {
23848                        instance_id: AgentInstanceId(71),
23849                        generation: SessionGeneration(1),
23850                    },
23851                },
23852                source_provider: agent("codex"),
23853            },
23854            &history,
23855        )
23856        .unwrap();
23857        let expected_context_hash = digest(&SHA256, pack.bytes())
23858            .as_ref()
23859            .iter()
23860            .map(|byte| format!("{byte:02x}"))
23861            .collect::<String>();
23862        let context = shared
23863            .context_catalog
23864            .write()
23865            .unwrap_or_else(|poisoned| poisoned.into_inner())
23866            .insert(pack)
23867            .unwrap();
23868        let spec = SpawnSpec {
23869            target: crate::protocol::SpawnTarget {
23870                node_id,
23871                workspace_id: workspace_id.clone(),
23872                worktree_id: None,
23873            },
23874            profile_id,
23875            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23876                "target-kimi-r1",
23877            ).unwrap(),
23878            overrides: crate::protocol::SpawnOverrides {
23879                context_id: crate::protocol::SpawnOverride::Set {
23880                    value: context.id.clone(),
23881                },
23882                ..crate::protocol::SpawnOverrides::default()
23883            },
23884            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23885            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23886            required_capabilities: SpawnRequiredCapabilities::default(),
23887        };
23888        let resolved = match shared.resolve_spawn_spec(&spec) {
23889            Ok(resolved) => resolved,
23890            Err(error) => panic!(
23891                "Kimi context-only resolve rejected: exact_code={:?} message={}",
23892                error.code,
23893                error.message,
23894            ),
23895        };
23896        let resolved_context = match shared.resolve_context(&resolved) {
23897            Ok(Some(resolved_context)) => resolved_context,
23898            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23899            Err(error) => panic!(
23900                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23901                error.code,
23902                error.message,
23903            ),
23904        };
23905        assert_eq!(resolved_context, context);
23906        let runtime_policy = match shared
23907            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23908            .await
23909        {
23910            Ok(runtime_policy) => runtime_policy,
23911            Err(error) => panic!(
23912                "Kimi RawPty admission rejected: exact_code={:?} message={}",
23913                error.code,
23914                error.message,
23915            ),
23916        };
23917        assert!(runtime_policy.raw_pty_lifecycle);
23918
23919        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23920        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23921            agent("kimi"),
23922            TransportKind::Pty,
23923            vec![EnvMutation {
23924                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23925                value: Some(OsString::from("must-remain-rejected")),
23926            }],
23927        )
23928        .unwrap();
23929        assert_eq!(
23930            launch_control
23931                .install_native_launch_environment_overlay(
23932                    AgentInstanceId(72),
23933                    generic_overlay,
23934                )
23935                .unwrap_err(),
23936            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23937        );
23938
23939        let diagnostic_address = SessionAddress {
23940            workspace_id: workspace_id.clone(),
23941            session: SessionKey {
23942                instance_id: AgentInstanceId(73),
23943                generation: SessionGeneration(1),
23944            },
23945        };
23946        let (diagnostic_overlay, diagnostic_materialization) = match shared
23947            .prepare_session_materialization(
23948                &diagnostic_address,
23949                &agent("kimi"),
23950                SessionMode::Pty,
23951                None,
23952                None,
23953                Some(&resolved_context),
23954                None,
23955            )
23956        {
23957            Ok(Some(prepared)) => prepared,
23958            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23959            Err(error) => panic!(
23960                "Kimi context-only materialization rejected: exact_code={:?} message={}",
23961                error.code,
23962                error.message,
23963            ),
23964        };
23965        let diagnostic_overlay = diagnostic_overlay
23966            .expect("Kimi context-only materialization omitted its environment overlay");
23967        assert!(matches!(
23968            &diagnostic_overlay,
23969            PreparedNativeLaunchOverlay::Environment(_),
23970        ));
23971        match shared.install_prepared_launch_overlay(
23972            diagnostic_address.session.instance_id,
23973            diagnostic_overlay,
23974        ) {
23975            Ok(None) => {}
23976            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23977            Err(error) => panic!(
23978                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23979                error.code,
23980                error.message,
23981            ),
23982        }
23983        assert!(launch_control.clear_native_instance_launch_overlay(
23984            diagnostic_address.session.instance_id,
23985        ));
23986        drop(diagnostic_materialization);
23987        assert!(shared
23988            .materializations
23989            .lock()
23990            .unwrap_or_else(|poisoned| poisoned.into_inner())
23991            .is_empty());
23992
23993        let node_task = tokio::spawn(server.run());
23994        let ready_client = timeout(Duration::from_secs(5), async {
23995            loop {
23996                match gate4agent_node_wire::LocalNodeClient::connect(
23997                    &endpoint,
23998                    &shared.node_id,
23999                    ClientRole::Observer,
24000                    "fixture-token",
24001                )
24002                .await
24003                {
24004                    Ok(client) => break client,
24005                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24006                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24007                }
24008            }
24009        })
24010        .await
24011        .expect("Kimi context-only Node did not become ready");
24012        assert_eq!(
24013            ready_client
24014                .hello()
24015                .snapshot
24016                .provider_runtime_statuses
24017                .iter()
24018                .find(|status| status.provider().as_str() == "kimi")
24019                .expect("Kimi runtime status is missing")
24020                .mode(),
24021            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24022        );
24023        drop(ready_client);
24024
24025        let receipt = match shared.spawn_from_spec(spec).await {
24026            Ok(receipt) => receipt,
24027            Err(error) => panic!(
24028                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24029                error.code,
24030                error.message,
24031            ),
24032        };
24033        assert_eq!(receipt.provider, agent("kimi"));
24034        assert_eq!(receipt.context.as_ref(), Some(&context));
24035        assert!(receipt.context_binding_is_valid());
24036        timeout(Duration::from_secs(5), async {
24037            while !proof_path.is_file() {
24038                sleep(Duration::from_millis(10)).await;
24039            }
24040        })
24041        .await
24042        .expect("Kimi context-only fixture did not write its proof");
24043
24044        let proof = std::fs::read_to_string(&proof_path).unwrap();
24045        let lines = proof.lines().collect::<Vec<_>>();
24046        assert_eq!(lines.len(), 7);
24047        assert_eq!(lines[0], "context-only");
24048        assert!(Path::new(lines[1]).is_absolute());
24049        assert_ne!(
24050            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24051            std::fs::canonicalize(&workspace_root).unwrap(),
24052        );
24053        assert_eq!(
24054            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24055            std::fs::canonicalize(&workspace_root).unwrap(),
24056        );
24057        assert_eq!(lines[3], expected_context_hash);
24058        assert_eq!(lines[4], "g4a-context-pack-v1");
24059        assert_eq!(lines[5], "codex");
24060        assert_eq!(lines[6], "2");
24061        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24062            session.instance_id == receipt.session.session.instance_id
24063                && session.agent_id.as_str() == "kimi"
24064        }));
24065
24066        shutdown.request_shutdown().await.unwrap();
24067        timeout(Duration::from_secs(5), node_task)
24068            .await
24069            .unwrap()
24070            .unwrap()
24071            .unwrap();
24072        drop(shutdown);
24073        drop(shared);
24074        std::fs::remove_dir_all(root).unwrap();
24075    }
24076
24077    #[cfg(feature = "fixture")]
24078    #[test]
24079    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24080        let root = temporary_workspace_root("clean-exit-constructor");
24081        std::fs::create_dir_all(&root).unwrap();
24082        let config = construction_test_config("clean-exit-constructor")
24083            .with_state_path(root.join("node-state.json"))
24084            .unwrap();
24085        let server = NodeServer::new_clean_exit_fixture(
24086            config,
24087            root.clone(),
24088            root.join("started.marker"),
24089            root.join("release.signal"),
24090        )
24091        .unwrap();
24092        assert_eq!(
24093            server
24094                .shared
24095                .enabled_providers
24096                .iter()
24097                .map(AgentId::as_str)
24098                .collect::<Vec<_>>(),
24099            ["claude"],
24100        );
24101        drop(server);
24102        std::fs::remove_dir_all(root).unwrap();
24103    }
24104
24105    #[test]
24106    fn production_registry_provider_contract_manifest_is_exact() {
24107        let registry = active_registry().unwrap();
24108        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24109        assert_eq!(
24110            providers
24111                .iter()
24112                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24113                .collect::<Vec<_>>(),
24114            vec![
24115                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24116                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24117                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24118                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24119            ]
24120        );
24121        assert_eq!(
24122            adapters
24123                .iter()
24124                .map(|contract| (
24125                    contract.provider.as_str(),
24126                    contract.family,
24127                    contract.adapter_id.as_str(),
24128                    contract.revision.as_str(),
24129                ))
24130                .collect::<Vec<_>>(),
24131            vec![
24132                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24133                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24134                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24135                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24136                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24137                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24138                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24139                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24140                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24141                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24142                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24143                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24144                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24145                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24146                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24147                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24148                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24149                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24150                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24151                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24152                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24153                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24154                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24155            ]
24156        );
24157    }
24158
24159    fn construction_test_config(name: &str) -> NodeServerConfig {
24160        let workspace = WorkspaceConfig::new(
24161            WorkspaceId::new("primary").unwrap(),
24162            std::env::current_dir().unwrap(),
24163        )
24164        .unwrap();
24165        NodeServerConfig::new(
24166            format!(r"\\.\pipe\gate4agent-{name}"),
24167            "fixture-token",
24168            NodeId::new(name).unwrap(),
24169            [workspace],
24170        )
24171        .unwrap()
24172    }
24173
24174    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24175        let adapter = AdapterBinding::new(
24176            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24177            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24178            gate4agent_types::AdapterVerification::SyntheticFixture,
24179        )
24180        .unwrap();
24181        let agent_ids = ["claude", "codex", "kimi"]
24182            .into_iter()
24183            .map(|agent_id| AgentId::new(agent_id).unwrap())
24184            .collect::<Vec<_>>();
24185        let adapters = gate4agent_catalog::AdapterRegistry::new(
24186            [
24187                AdapterFamily::PtySemantic,
24188                AdapterFamily::Pipe,
24189                AdapterFamily::OneShot,
24190                AdapterFamily::Acp,
24191                AdapterFamily::Hook,
24192                AdapterFamily::ManagedHook,
24193                AdapterFamily::History,
24194                AdapterFamily::Resume,
24195                AdapterFamily::SessionOptions,
24196                AdapterFamily::CapabilityProbe,
24197            ]
24198            .into_iter()
24199            .map(|family| gate4agent_catalog::AdapterDescriptor {
24200                family,
24201                binding: adapter.clone(),
24202                agents: agent_ids.clone(),
24203            }),
24204        )
24205        .unwrap();
24206        let specs = agent_ids
24207            .iter()
24208            .map(|agent_id| {
24209                let agent_id = agent_id.as_str();
24210                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24211                spec.revision =
24212                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24213                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24214                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24215                    adapter: adapter.clone(),
24216                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24217                    launch_override: None,
24218                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24219                });
24220                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24221                    adapter: adapter.clone(),
24222                    launch_override: None,
24223                });
24224                spec.capabilities.adapters.hook = Some(adapter.clone());
24225                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24226                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24227                spec.capabilities.adapters.history = Some(adapter.clone());
24228                spec.capabilities.adapters.resume = Some(adapter.clone());
24229                spec.capabilities.adapters.session_options = Some(adapter.clone());
24230                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24231                spec
24232            })
24233            .collect::<Vec<_>>();
24234        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24235    }
24236
24237    /// The real fleet's manifest is accepted by a real server construction.
24238    ///
24239    /// The count is asserted against `active_registry` itself rather than a
24240    /// literal, so this stays a test that construction succeeds for whatever
24241    /// the fleet is -- which is the property worth guarding -- instead of a
24242    /// second place the fleet size is written down and has to be kept in
24243    /// step with the first.
24244    #[test]
24245    fn node_server_construction_accepts_the_production_manifest() {
24246        let registry = active_registry().unwrap();
24247        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24248        assert_eq!(providers.len(), registry.iter().count());
24249        assert!(!providers.is_empty());
24250        let server = NodeServer::new_with_registry(
24251            construction_test_config("manifest-production-bound"),
24252            registry,
24253        )
24254        .unwrap();
24255        drop(server);
24256    }
24257
24258    #[test]
24259    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24260        let registry = maximum_size_provider_manifest_registry();
24261        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24262        assert_eq!(providers.len(), 3);
24263        assert_eq!(adapters.len(), 30);
24264        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24265        let error = match NodeServer::new_with_registry(
24266            construction_test_config("manifest-handshake-overflow"),
24267            registry,
24268        ) {
24269            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24270            Err(error) => error,
24271        };
24272        assert!(matches!(
24273            error,
24274            NodeServerError::ProviderContractManifest(message)
24275                if message.contains("authentication binding length")
24276                    && message.contains("8192-byte limit")
24277        ));
24278    }
24279
24280    #[test]
24281    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24282        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24283        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24284        let registry = AgentRegistry::new([spec]).unwrap();
24285        assert!(matches!(
24286            provider_contract_manifest(&registry),
24287            Err(NodeServerError::ProviderContractManifest(message))
24288                if message.contains("exceeds the 128-byte limit")
24289        ));
24290    }
24291
24292    #[test]
24293    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24294        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24295        spec.revision = "invalid revision".to_owned();
24296        let registry = AgentRegistry::new([spec]).unwrap();
24297        assert!(matches!(
24298            provider_contract_manifest(&registry),
24299            Err(NodeServerError::ProviderContractManifest(message))
24300                if message.contains("bounded lowercase ASCII")
24301        ));
24302    }
24303
24304    #[test]
24305    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24306        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24307        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24308        let conflicting_binding = builtin_registry()
24309            .get_by_id("codex")
24310            .unwrap()
24311            .capabilities
24312            .adapters
24313            .one_shot
24314            .clone()
24315            .unwrap();
24316        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24317        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24318        pipe.adapter = transport_binding;
24319        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24320        let registry = AgentRegistry::new([spec]).unwrap();
24321        assert!(matches!(
24322            provider_contract_manifest(&registry),
24323            Err(NodeServerError::ProviderContractManifest(message))
24324                if message.contains("conflicting OneShot adapter bindings")
24325        ));
24326    }
24327
24328    #[test]
24329    fn public_node_failures_expose_only_fixed_categories() {
24330        let provider = failure(
24331            NodeFailureCode::BackendOperationFailed,
24332            r"provider bearer-secret failed at C:\private\session.jsonl",
24333        );
24334        assert_eq!(provider.message, "backend-operation-failed");
24335        assert!(!provider.message.contains("bearer-secret"));
24336        assert!(!provider.message.contains(r"C:\private"));
24337        let persistence = persistence_failure(io::Error::new(
24338            io::ErrorKind::Other,
24339            r"state write failed at C:\private\state-v1.json",
24340        ));
24341        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24342        assert!(!persistence.message.contains(r"C:\private"));
24343
24344        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24345            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24346            assert_eq!(
24347                generic.to_string(),
24348                "node durable state failed: durable-state-load-failed",
24349            );
24350        }
24351    }
24352
24353    #[test]
24354    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24355        let sequence = b"\x1b[1;5D".to_vec();
24356        assert_eq!(
24357            terminal_bytes_action(sequence.clone()).unwrap(),
24358            InputAction::TerminalBytes(sequence),
24359        );
24360        assert_eq!(
24361            terminal_bytes_action(Vec::new()).unwrap_err().code,
24362            NodeFailureCode::InvalidRequest,
24363        );
24364        assert_eq!(
24365            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24366                .unwrap_err()
24367                .code,
24368            NodeFailureCode::InvalidRequest,
24369        );
24370    }
24371
24372    #[test]
24373    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24374        let root = temporary_workspace_root("exclusive-state-owner");
24375        std::fs::create_dir_all(&root).unwrap();
24376        let workspace = WorkspaceConfig::new(
24377            WorkspaceId::new("primary").unwrap(),
24378            &root,
24379        )
24380        .unwrap();
24381        let state_path = root.join("state-v1.json");
24382        let endpoint = format!(
24383            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24384            std::process::id(),
24385            std::time::SystemTime::now()
24386                .duration_since(std::time::UNIX_EPOCH)
24387                .unwrap()
24388                .as_nanos(),
24389        );
24390        let config = NodeServerConfig::new(
24391            endpoint,
24392            "fixture-token",
24393            NodeId::new("node-1").unwrap(),
24394            [workspace],
24395        )
24396        .unwrap()
24397        .with_state_path(&state_path)
24398        .unwrap();
24399
24400        let first = NodeServer::new(config.clone()).unwrap();
24401        let error = match NodeServer::new(config.clone()) {
24402            Ok(_) => panic!("a second node server acquired the same durable state path"),
24403            Err(error) => error,
24404        };
24405        assert!(matches!(
24406            &error,
24407            NodeServerError::DurableState(error)
24408                if error.kind() == io::ErrorKind::WouldBlock
24409        ));
24410        assert_eq!(
24411            error.to_string(),
24412            "node durable state failed: durable-state-lock-failed",
24413        );
24414        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24415        drop(first);
24416        let second = NodeServer::new(config).unwrap();
24417        drop(second);
24418        std::fs::remove_dir_all(root).unwrap();
24419    }
24420
24421    #[test]
24422    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24423        let catalog = active_registry().unwrap();
24424        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24425        let workspace_id = WorkspaceId::new("primary").unwrap();
24426        let workspace = WorkspaceConfig::new(
24427            workspace_id.clone(),
24428            std::env::current_dir().unwrap(),
24429        )
24430        .unwrap();
24431        let shared = NodeShared::new(
24432            handle,
24433            "fixture-token".to_owned(),
24434            NodeId::new("node-1").unwrap(),
24435            vec![workspace.clone()],
24436            vec![agent("claude"), agent("codex"), agent("kimi")],
24437        );
24438        let instance_id = AgentInstanceId(17);
24439        let generation = SessionGeneration(3);
24440        assert!(shared.address_for(instance_id, generation).is_none());
24441        shared.bind_session(instance_id, workspace_id.clone(), generation);
24442        let address = SessionAddress {
24443            workspace_id,
24444            session: SessionKey { instance_id, generation },
24445        };
24446        assert_eq!(
24447            shared.workspace_root(&address.workspace_id).unwrap(),
24448            workspace.canonical_root,
24449        );
24450        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24451        shared.remove_binding(&address);
24452        assert!(shared.address_for(instance_id, generation).is_none());
24453    }
24454
24455    #[test]
24456    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24457        let address = SessionAddress {
24458            workspace_id: WorkspaceId::new("primary").unwrap(),
24459            session: SessionKey {
24460                instance_id: AgentInstanceId(7),
24461                generation: SessionGeneration(2),
24462            },
24463        };
24464        let record_id = SessionRecordId::new("record-a").unwrap();
24465        let size = gate4agent_types::TerminalSize {
24466            rows: 24,
24467            columns: 80,
24468        };
24469        let address_requests = [
24470            NodeRequest::Resume {
24471                session: address.clone(),
24472                terminal_size: size,
24473                initial_prompt: None,
24474            },
24475            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24476            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24477            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24478            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24479            NodeRequest::TerminalControl {
24480                session: address.clone(),
24481                control: TerminalControl::Enter,
24482            },
24483            NodeRequest::Resize { session: address.clone(), size },
24484            NodeRequest::Interrupt { session: address.clone() },
24485            NodeRequest::Stop { session: address.clone(), force: false },
24486            NodeRequest::Remove { session: address },
24487        ];
24488        for request in &address_requests {
24489            assert!(request_requires_open_provider_ids_with(
24490                request,
24491                |_| Some(agent("third-party-agent")),
24492                |_| Some(agent("claude")),
24493            ));
24494            assert!(!request_requires_open_provider_ids_with(
24495                request,
24496                |_| Some(agent("claude")),
24497                |_| Some(agent("claude")),
24498            ));
24499            assert!(request_requires_open_provider_ids_with(
24500                request,
24501                |_| None,
24502                |_| Some(agent("claude")),
24503            ));
24504        }
24505
24506        let record_requests = [
24507            NodeRequest::RenameSessionRecord {
24508                record_id: record_id.clone(),
24509                display_name: "renamed".to_owned(),
24510            },
24511            NodeRequest::ResumeSessionRecord {
24512                record_id: record_id.clone(),
24513                terminal_size: size,
24514                initial_prompt: None,
24515            },
24516            NodeRequest::ForgetSessionRecord { record_id },
24517        ];
24518        for request in &record_requests {
24519            assert!(request_requires_open_provider_ids_with(
24520                request,
24521                |_| Some(agent("claude")),
24522                |_| Some(agent("grok")),
24523            ));
24524            assert!(!request_requires_open_provider_ids_with(
24525                request,
24526                |_| Some(agent("claude")),
24527                |_| Some(agent("kimi")),
24528            ));
24529            assert!(request_requires_open_provider_ids_with(
24530                request,
24531                |_| Some(agent("claude")),
24532                |_| None,
24533            ));
24534        }
24535
24536        assert!(request_requires_open_provider_ids_with(
24537            &NodeRequest::Spawn {
24538                workspace_id: WorkspaceId::new("primary").unwrap(),
24539                provider: agent("third-party-agent"),
24540                mode: SessionMode::Pty,
24541                terminal_size: size,
24542                initial_prompt: None,
24543            },
24544            |_| None,
24545            |_| None,
24546        ));
24547        assert!(!request_requires_open_provider_ids_with(
24548            &NodeRequest::Snapshot,
24549            |_| None,
24550            |_| None,
24551        ));
24552        assert!(request_requires_open_provider_ids_with(
24553            &NodeRequest::ForgetContextPack {
24554                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24555            },
24556            |_| None,
24557            |_| None,
24558        ));
24559    }
24560
24561    #[test]
24562    fn legacy_projection_filters_open_record_replies_and_event_history() {
24563        let catalog = active_registry().unwrap();
24564        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24565        let workspace = WorkspaceConfig::new(
24566            WorkspaceId::new("primary").unwrap(),
24567            std::env::current_dir().unwrap(),
24568        )
24569        .unwrap();
24570        let shared = NodeShared::new(
24571            handle,
24572            "fixture-token".to_owned(),
24573            NodeId::new("node-1").unwrap(),
24574            vec![workspace],
24575            vec![agent("claude"), agent("grok")],
24576        );
24577        let legacy = record("claude", "legacy-record");
24578        let open = record("grok", "open-record");
24579        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24580            record: legacy.clone(),
24581        });
24582        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24583            record: open.clone(),
24584        });
24585        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24586            record_id: legacy.record_id.clone(),
24587        });
24588        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24589            record_id: open.record_id.clone(),
24590        });
24591        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24592        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24593        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24594        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24595
24596        let mut record_reply = ResponseEnvelope {
24597            request_id: 1,
24598            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24599        };
24600        project_response_legacy_provider_ids(&shared, &mut record_reply);
24601        assert!(matches!(
24602            record_reply.result,
24603            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24604        ));
24605
24606        let mut resync = ResponseEnvelope {
24607            request_id: 2,
24608            result: Ok(NodeResponse::Resync {
24609                event_sequence: open_removed.sequence,
24610                oldest_available_sequence: 1,
24611                snapshot: shared.snapshot(),
24612                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24613            }),
24614        };
24615        project_response_legacy_provider_ids(&shared, &mut resync);
24616        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24617            panic!("legacy projection changed the resync response kind");
24618        };
24619        assert!(snapshot.session_records.iter().all(|item| {
24620            provider_id_is_legacy(&item.provider)
24621        }));
24622        assert_eq!(events.len(), 2);
24623        assert!(events.iter().all(|event| matches!(
24624            &event.event,
24625            NodeEvent::SessionRecordUpserted { record }
24626                if provider_id_is_legacy(&record.provider)
24627        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24628    }
24629
24630    #[test]
24631    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24632        let catalog = active_registry().unwrap();
24633        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24634        let workspace_id = WorkspaceId::new("primary").unwrap();
24635        let workspace = WorkspaceConfig::new(
24636            workspace_id.clone(),
24637            std::env::current_dir().unwrap(),
24638        )
24639        .unwrap();
24640        let shared = NodeShared::new(
24641            handle,
24642            "fixture-token".to_owned(),
24643            NodeId::new("node-1").unwrap(),
24644            vec![workspace],
24645            vec![agent("claude")],
24646        );
24647        for index in 0..CONTROL_SESSIONS_MAX {
24648            shared.bind_session(
24649                AgentInstanceId((index + 1) as u64),
24650                workspace_id.clone(),
24651                SessionGeneration(1),
24652            );
24653        }
24654        let error = shared.ensure_binding_capacity().unwrap_err();
24655        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24656    }
24657
24658    #[tokio::test]
24659    async fn provider_spawn_admission_rejects_before_session_mutation() {
24660        let catalog = active_registry().unwrap();
24661        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24662        let workspace = WorkspaceConfig::new(
24663            WorkspaceId::new("primary").unwrap(),
24664            std::env::current_dir().unwrap(),
24665        )
24666        .unwrap();
24667        let mut shared = NodeShared::new(
24668            handle,
24669            "fixture-token".to_owned(),
24670            NodeId::new("node-provider-admission").unwrap(),
24671            vec![workspace],
24672            vec![agent("claude")],
24673        );
24674        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24675            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24676        ])
24677        .unwrap();
24678        let terminal_size = gate4agent_types::TerminalSize {
24679            rows: 24,
24680            columns: 80,
24681        };
24682
24683        let unavailable = shared
24684            .spawn_session(
24685                WorkspaceId::new("primary").unwrap(),
24686                agent("claude"),
24687                SessionMode::Pty,
24688                terminal_size,
24689                None,
24690            )
24691            .await
24692            .unwrap_err();
24693        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24694        assert_eq!(unavailable.message, "backend-operation-failed");
24695
24696        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24697            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24698                agent("claude"),
24699                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24700            ),
24701        ])
24702        .unwrap();
24703        let semantic = shared
24704            .spawn_session(
24705                WorkspaceId::new("primary").unwrap(),
24706                agent("claude"),
24707                SessionMode::Inline,
24708                terminal_size,
24709                None,
24710            )
24711            .await
24712            .unwrap_err();
24713        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24714        assert_eq!(semantic.message, "unsupported-capability");
24715
24716        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24717        assert!(shared
24718            .session_bindings
24719            .lock()
24720            .unwrap_or_else(|poisoned| poisoned.into_inner())
24721            .is_empty());
24722        assert!(shared
24723            .session_records
24724            .lock()
24725            .unwrap_or_else(|poisoned| poisoned.into_inner())
24726            .records
24727            .is_empty());
24728        assert!(shared.handle.snapshot().sessions.is_empty());
24729    }
24730
24731    #[tokio::test]
24732    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24733        let catalog = active_registry().unwrap();
24734        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24735        let workspace_id = WorkspaceId::new("primary").unwrap();
24736        let workspace = WorkspaceConfig::new(
24737            workspace_id.clone(),
24738            std::env::current_dir().unwrap(),
24739        )
24740        .unwrap();
24741        let shared = NodeShared::new(
24742            handle,
24743            "fixture-token".to_owned(),
24744            NodeId::new("node-raw-spawn-record").unwrap(),
24745            vec![workspace],
24746            vec![agent("claude")],
24747        );
24748        let address = SessionAddress {
24749            workspace_id,
24750            session: SessionKey {
24751                instance_id: AgentInstanceId(71),
24752                generation: SessionGeneration::default(),
24753            },
24754        };
24755
24756        let record_id = shared
24757            .bind_spawn_session(
24758                &address,
24759                agent("claude"),
24760                SessionMode::Pty,
24761                ProviderRuntimePolicy::raw_pty(),
24762                SpawnRecordPolicy::Always,
24763                None,
24764            )
24765            .unwrap()
24766            .unwrap();
24767        shared
24768            .handle
24769            .dispatch(shared.prepare_command(ControlCommand::Register {
24770                instance_id: address.session.instance_id,
24771                agent_id: agent("claude"),
24772                transport: TransportKind::Pty,
24773            }))
24774            .unwrap();
24775        runtime.tick().await;
24776
24777        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24778            session.instance_id == address.session.instance_id
24779                && session.generation == address.session.generation
24780        }));
24781        let record = shared.record(&record_id).unwrap();
24782        assert_eq!(record.display_name, "claude #1");
24783        assert_eq!(record.state, ManagedSessionState::Live);
24784        assert_eq!(record.active_session.as_ref(), Some(&address));
24785        assert!(record.provider_session.is_none());
24786        let binding_record_id = shared
24787            .session_bindings
24788            .lock()
24789            .unwrap_or_else(|poisoned| poisoned.into_inner())
24790            .get(&address.session.instance_id)
24791            .and_then(|binding| binding.record_id.clone());
24792        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24793
24794        let renamed = shared
24795            .rename_session_record(&record_id, "raw review".to_owned())
24796            .unwrap();
24797        assert_eq!(renamed.record_id, record_id);
24798        assert_eq!(renamed.display_name, "raw review");
24799        shared.reconcile_managed_record(
24800            &record_id,
24801            &address,
24802            &ControlEvent {
24803                sequence: 1,
24804                command_id: None,
24805                instance_id: address.session.instance_id,
24806                generation: address.session.generation,
24807                event: ControlEventKind::Running { process_id: None },
24808            },
24809            false,
24810            false,
24811        );
24812        let running = shared.record(&record_id).unwrap();
24813        assert_eq!(running.state, ManagedSessionState::Live);
24814        assert_eq!(running.active_session.as_ref(), Some(&address));
24815        assert_eq!(running.display_name, "raw review");
24816
24817        shared.reconcile_managed_record(
24818            &record_id,
24819            &address,
24820            &ControlEvent {
24821                sequence: 2,
24822                command_id: None,
24823                instance_id: address.session.instance_id,
24824                generation: address.session.generation,
24825                event: ControlEventKind::Exited {
24826                    exit_code: Some(0),
24827                    forced: false,
24828                },
24829            },
24830            false,
24831            false,
24832        );
24833        let detached = shared.record(&record_id).unwrap();
24834        assert_eq!(detached.state, ManagedSessionState::Unavailable);
24835        assert!(detached.active_session.is_none());
24836        assert_eq!(detached.display_name, "raw review");
24837        let renamed_detached = shared
24838            .rename_session_record(&record_id, "raw review detached".to_owned())
24839            .unwrap();
24840        assert_eq!(renamed_detached.record_id, record_id);
24841        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24842        assert!(renamed_detached.active_session.is_none());
24843        assert_eq!(renamed_detached.display_name, "raw review detached");
24844        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24845    }
24846
24847    #[test]
24848    fn legacy_raw_spawn_binding_remains_untracked() {
24849        let catalog = active_registry().unwrap();
24850        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24851        let workspace_id = WorkspaceId::new("primary").unwrap();
24852        let workspace = WorkspaceConfig::new(
24853            workspace_id.clone(),
24854            std::env::current_dir().unwrap(),
24855        )
24856        .unwrap();
24857        let shared = NodeShared::new(
24858            handle,
24859            "fixture-token".to_owned(),
24860            NodeId::new("node-legacy-raw-spawn").unwrap(),
24861            vec![workspace],
24862            vec![agent("claude")],
24863        );
24864        let address = SessionAddress {
24865            workspace_id,
24866            session: SessionKey {
24867                instance_id: AgentInstanceId(72),
24868                generation: SessionGeneration::default(),
24869            },
24870        };
24871
24872        let record_id = shared
24873            .bind_spawn_session(
24874                &address,
24875                agent("claude"),
24876                SessionMode::Pty,
24877                ProviderRuntimePolicy::raw_pty(),
24878                SpawnRecordPolicy::ProviderIdentityOnly,
24879                None,
24880            )
24881            .unwrap();
24882
24883        assert!(record_id.is_none());
24884        let binding_record_id = shared
24885            .session_bindings
24886            .lock()
24887            .unwrap_or_else(|poisoned| poisoned.into_inner())
24888            .get(&address.session.instance_id)
24889            .and_then(|binding| binding.record_id.clone());
24890        assert!(binding_record_id.is_none());
24891        assert!(shared.snapshot().session_records.is_empty());
24892    }
24893
24894    #[tokio::test]
24895    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24896        let catalog = active_registry().unwrap();
24897        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24898        let workspace_id = WorkspaceId::new("primary").unwrap();
24899        let workspace = WorkspaceConfig::new(
24900            workspace_id.clone(),
24901            std::env::current_dir().unwrap(),
24902        )
24903        .unwrap();
24904        let mut shared = NodeShared::new(
24905            handle,
24906            "fixture-token".to_owned(),
24907            NodeId::new("node-raw-semantic-gate").unwrap(),
24908            vec![workspace],
24909            vec![agent("claude")],
24910        );
24911        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24912            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24913        ])
24914        .unwrap();
24915        shared
24916            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24917            .unwrap();
24918        let address = SessionAddress {
24919            workspace_id,
24920            session: SessionKey {
24921                instance_id: AgentInstanceId(72),
24922                generation: SessionGeneration::default(),
24923            },
24924        };
24925        shared.bind_session_with_policy(
24926            &address,
24927            ProviderRuntimePolicy::raw_pty(),
24928            None,
24929        );
24930        shared
24931            .handle
24932            .dispatch(shared.prepare_command(ControlCommand::Register {
24933                instance_id: address.session.instance_id,
24934                agent_id: agent("claude"),
24935                transport: TransportKind::Pty,
24936            }))
24937            .unwrap();
24938        runtime.tick().await;
24939        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24940        let revision_before = shared.handle.snapshot().revision;
24941        let history_sequence_before = shared.current_sequence();
24942        let terminal_size = gate4agent_types::TerminalSize {
24943            rows: 24,
24944            columns: 80,
24945        };
24946
24947        for request in [
24948            NodeRequest::Prompt {
24949                session: address.clone(),
24950                text: "prompt".to_owned(),
24951            },
24952            NodeRequest::Paste {
24953                session: address.clone(),
24954                text: "paste".to_owned(),
24955            },
24956            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
24957            // the same semantic capabilities as `Prompt`/`Paste`. A bare
24958            // (promptless) resume is a raw PTY relaunch and IS admitted under
24959            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
24960            // in `provider_runtime.rs`; it is deliberately not exercised here.
24961            NodeRequest::Resume {
24962                session: address.clone(),
24963                terminal_size,
24964                initial_prompt: Some("resume".to_owned()),
24965            },
24966        ] {
24967            let error = process_request_inner(
24968                &shared,
24969                77,
24970                ClientRole::Operator,
24971                request,
24972            )
24973            .await
24974            .unwrap_err();
24975            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24976        }
24977
24978        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24979        assert_eq!(shared.handle.snapshot().revision, revision_before);
24980        assert_eq!(shared.current_sequence(), history_sequence_before);
24981        let bindings = shared
24982            .session_bindings
24983            .lock()
24984            .unwrap_or_else(|poisoned| poisoned.into_inner());
24985        let binding = bindings.get(&address.session.instance_id).unwrap();
24986        assert_eq!(binding.generation, address.session.generation);
24987        assert!(binding.pending_resume.is_none());
24988        assert!(binding.record_id.is_none());
24989    }
24990
24991    #[tokio::test]
24992    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24993        let catalog = active_registry().unwrap();
24994        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24995        let primary = WorkspaceConfig::new(
24996            WorkspaceId::new("primary").unwrap(),
24997            std::env::current_dir().unwrap(),
24998        )
24999        .unwrap();
25000        let shared = NodeShared::new(
25001            handle,
25002            "fixture-token".to_owned(),
25003            NodeId::new("node-1").unwrap(),
25004            vec![primary],
25005            vec![agent("claude")],
25006        );
25007        shared
25008            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25009            .unwrap();
25010        let before = shared.snapshot();
25011        let error = process_request_inner(
25012            &shared,
25013            77,
25014            ClientRole::Operator,
25015            NodeRequest::RegisterWorkspace {
25016                workspace_id: WorkspaceId::new("foreign").unwrap(),
25017                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25018            },
25019        )
25020        .await
25021        .unwrap_err();
25022        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25023        assert_eq!(error.message, "invalid-request");
25024        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25025    }
25026
25027    #[tokio::test]
25028    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25029        let catalog = active_registry().unwrap();
25030        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25031        let primary_id = WorkspaceId::new("primary").unwrap();
25032        let primary = WorkspaceConfig::new(
25033            primary_id.clone(),
25034            std::env::current_dir().unwrap(),
25035        )
25036        .unwrap();
25037        let shared = NodeShared::new(
25038            handle,
25039            "fixture-token".to_owned(),
25040            NodeId::new("node-1").unwrap(),
25041            vec![primary.clone()],
25042            vec![agent("claude")],
25043        );
25044        let secondary_id = WorkspaceId::new("secondary").unwrap();
25045        let secondary = shared
25046            .register_workspace(
25047                secondary_id.clone(),
25048                std::env::temp_dir().to_string_lossy().into_owned(),
25049            )
25050            .await
25051            .unwrap();
25052        assert_eq!(secondary.workspace_id, secondary_id);
25053        assert!(secondary.sessions.is_empty());
25054        assert_eq!(shared.snapshot().workspaces.len(), 2);
25055
25056        let duplicate_id = shared
25057            .register_workspace(
25058                secondary_id.clone(),
25059                primary.canonical_root().to_owned(),
25060            )
25061            .await
25062            .unwrap_err();
25063        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25064        let duplicate_root = shared
25065            .register_workspace(
25066                WorkspaceId::new("duplicate-root").unwrap(),
25067                windows_path_text(&secondary.canonical_root).to_owned(),
25068            )
25069            .await
25070            .unwrap_err();
25071        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25072
25073        let address = SessionAddress {
25074            workspace_id: secondary_id.clone(),
25075            session: SessionKey {
25076                instance_id: AgentInstanceId(41),
25077                generation: SessionGeneration(1),
25078            },
25079        };
25080        shared.bind_session(
25081            address.session.instance_id,
25082            secondary_id.clone(),
25083            address.session.generation,
25084        );
25085        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25086        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25087        shared.remove_binding(&address);
25088        shared.unregister_workspace(&secondary_id).unwrap();
25089        assert_eq!(shared.snapshot().workspaces.len(), 1);
25090
25091        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25092        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25093        let history = shared
25094            .history
25095            .lock()
25096            .unwrap_or_else(|poisoned| poisoned.into_inner());
25097        assert!(matches!(
25098            &history.events[0],
25099            NodeEventEnvelope {
25100                sequence: 1,
25101                event: NodeEvent::WorkspaceAdded { workspace },
25102            } if workspace.workspace_id == secondary_id
25103        ));
25104        assert!(matches!(
25105            &history.events[1],
25106            NodeEventEnvelope {
25107                sequence: 2,
25108                event: NodeEvent::WorkspaceRemoved { workspace_id },
25109            } if workspace_id == &secondary_id
25110        ));
25111    }
25112
25113    #[tokio::test]
25114    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25115        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25116        std::fs::create_dir_all(&secondary_root).unwrap();
25117        let catalog = active_registry().unwrap();
25118        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25119        let primary_id = WorkspaceId::new("primary").unwrap();
25120        let secondary_id = WorkspaceId::new("secondary").unwrap();
25121        let shared = NodeShared::new(
25122            handle,
25123            "fixture-token".to_owned(),
25124            NodeId::new("node-session-reference-index").unwrap(),
25125            vec![
25126                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25127                    .unwrap(),
25128                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25129            ],
25130            vec![agent("claude"), agent("codex")],
25131        );
25132        let identity = ProviderSessionIdentity {
25133            key: gate4agent_types::ProviderSessionKey::SessionId,
25134            id: "provider-session-42".to_owned(),
25135            transcript_path: None,
25136        };
25137        let index_request = |workspace_id: WorkspaceId,
25138                             provider: AgentId,
25139                             identity: ProviderSessionIdentity,
25140                             display_name: &str| {
25141            NodeRequest::IndexProviderSession {
25142                workspace_id,
25143                provider,
25144                identity,
25145                display_name: display_name.to_owned(),
25146            }
25147        };
25148        let request = index_request(
25149            primary_id.clone(),
25150            agent("claude"),
25151            identity.clone(),
25152            "release shepherd",
25153        );
25154        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25155            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25156        }));
25157        let observer = process_request_inner(
25158            &shared,
25159            77,
25160            ClientRole::Observer,
25161            request.clone(),
25162        )
25163        .await
25164        .unwrap_err();
25165        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25166        let uncontrolled = process_request_inner(
25167            &shared,
25168            77,
25169            ClientRole::Operator,
25170            request.clone(),
25171        )
25172        .await
25173        .unwrap_err();
25174        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25175        shared
25176            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25177            .unwrap();
25178
25179        let indexed = process_request_inner(
25180            &shared,
25181            77,
25182            ClientRole::Operator,
25183            request.clone(),
25184        )
25185        .await
25186        .unwrap();
25187        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25188            panic!("provider session reference index returned another response");
25189        };
25190        assert_eq!(record.state, ManagedSessionState::Dormant);
25191        assert_eq!(record.mode, SessionMode::Pty);
25192        assert_eq!(record.workspace_id, primary_id);
25193        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25194        assert!(record.active_session.is_none());
25195        assert!(record.environment_profile.is_none());
25196        assert!(record.bundle.is_none());
25197        assert!(record.context.is_none());
25198
25199        let repeated = process_request_inner(
25200            &shared,
25201            77,
25202            ClientRole::Operator,
25203            index_request(
25204                primary_id.clone(),
25205                agent("claude"),
25206                identity.clone(),
25207                "ignored replacement",
25208            ),
25209        )
25210        .await
25211        .unwrap();
25212        assert!(matches!(
25213            repeated,
25214            NodeResponse::ProviderSessionIndexed { record: ref existing }
25215                if existing.record_id == record.record_id
25216                    && existing.display_name == "release shepherd"
25217        ));
25218        let conflicting_workspace = process_request_inner(
25219            &shared,
25220            77,
25221            ClientRole::Operator,
25222            index_request(
25223                secondary_id,
25224                agent("claude"),
25225                identity.clone(),
25226                "release shepherd",
25227            ),
25228        )
25229        .await
25230        .unwrap_err();
25231        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25232        let conflicting_provider = process_request_inner(
25233            &shared,
25234            77,
25235            ClientRole::Operator,
25236            index_request(
25237                primary_id.clone(),
25238                agent("codex"),
25239                identity.clone(),
25240                "release shepherd",
25241            ),
25242        )
25243        .await
25244        .unwrap_err();
25245        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25246        let transcript_rejected = process_request_inner(
25247            &shared,
25248            77,
25249            ClientRole::Operator,
25250            index_request(
25251                primary_id,
25252                agent("claude"),
25253                ProviderSessionIdentity {
25254                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25255                    ..identity
25256                },
25257                "release shepherd",
25258            ),
25259        )
25260        .await
25261        .unwrap_err();
25262        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25263        let upserts = shared
25264            .history
25265            .lock()
25266            .unwrap_or_else(|poisoned| poisoned.into_inner())
25267            .events
25268            .iter()
25269            .filter(|event| matches!(
25270                &event.event,
25271                NodeEvent::SessionRecordUpserted { record: published }
25272                    if published.record_id == record.record_id
25273            ))
25274            .count();
25275        assert_eq!(upserts, 1);
25276        std::fs::remove_dir_all(secondary_root).unwrap();
25277    }
25278
25279    #[tokio::test]
25280    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25281        let fixture_root = temporary_workspace_root("standalone-independent");
25282        let target = fixture_root.join("independent");
25283        std::fs::create_dir_all(&fixture_root).unwrap();
25284        let catalog = active_registry().unwrap();
25285        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25286        let selected = WorkspaceConfig::new(
25287            WorkspaceId::new("primary").unwrap(),
25288            std::env::current_dir().unwrap(),
25289        )
25290        .unwrap();
25291        let selected_root = selected.canonical_root().to_owned();
25292        let shared = NodeShared::new(
25293            handle,
25294            "fixture-token".to_owned(),
25295            NodeId::new("node-standalone").unwrap(),
25296            vec![selected],
25297            vec![agent("claude")],
25298        );
25299        let workspace_id = WorkspaceId::new("independent").unwrap();
25300        let request = NodeRequest::CreateStandaloneWorkspace {
25301            workspace_id: workspace_id.clone(),
25302            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25303            initial_branch: Some("main".to_owned()),
25304        };
25305        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25306            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25307        }));
25308        assert!(!request_uses_unnegotiated_capability(
25309            &request,
25310            &baseline_capabilities().unwrap(),
25311        ));
25312        assert!(request_uses_unnegotiated_capability(&request, &[]));
25313
25314        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25315            .await
25316            .unwrap_err();
25317        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25318        assert!(!target.exists());
25319        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25320            .await
25321            .unwrap_err();
25322        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25323        assert!(!target.exists());
25324        shared
25325            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25326            .unwrap();
25327
25328        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25329            .await
25330            .unwrap();
25331        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25332            panic!("standalone workspace creation returned another response");
25333        };
25334        assert_eq!(workspace.workspace_id, workspace_id);
25335        assert!(target.join(".git").is_dir());
25336        let branch = run_git_bounded(
25337            target.to_str().unwrap(),
25338            &["branch", "--show-current"],
25339            GIT_OUTPUT_MAX_BYTES,
25340        )
25341        .await
25342        .unwrap();
25343        assert!(branch.success);
25344        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25345        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25346        assert_eq!(worktrees.len(), 1);
25347        let listed_root = WorkspaceConfig::new(
25348            WorkspaceId::new("listed-independent").unwrap(),
25349            &worktrees[0].path,
25350        )
25351        .unwrap();
25352        assert!(platform::roots_equal(
25353            listed_root.canonical_root(),
25354            windows_path_text(&workspace.canonical_root),
25355        ));
25356        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25357        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25358            snapshot.workspace_id == workspace_id
25359                && snapshot.canonical_root == workspace.canonical_root
25360        }));
25361        assert!(shared
25362            .history
25363            .lock()
25364            .unwrap_or_else(|poisoned| poisoned.into_inner())
25365            .events
25366            .iter()
25367            .any(|event| matches!(
25368                &event.event,
25369                NodeEvent::WorkspaceAdded { workspace }
25370                    if workspace.workspace_id == workspace_id
25371            )));
25372        std::fs::remove_dir_all(fixture_root).unwrap();
25373    }
25374
25375    #[tokio::test]
25376    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25377        let fixture_root = temporary_workspace_root("standalone-compensation");
25378        let created_target = fixture_root.join("created-target");
25379        let existing_target = fixture_root.join("existing-target");
25380        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25381        let invalid_state_path = invalid_state_parent.join("state.json");
25382        std::fs::create_dir_all(&existing_target).unwrap();
25383        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25384        let catalog = active_registry().unwrap();
25385        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25386        let primary = WorkspaceConfig::new(
25387            WorkspaceId::new("primary").unwrap(),
25388            std::env::current_dir().unwrap(),
25389        )
25390        .unwrap();
25391        let mut shared = NodeShared::new(
25392            handle,
25393            "fixture-token".to_owned(),
25394            NodeId::new("node-standalone-compensation").unwrap(),
25395            vec![primary],
25396            vec![agent("claude")],
25397        );
25398        shared.state_path = Some(invalid_state_path);
25399
25400        let created_recovery = shared
25401            .create_standalone_workspace(
25402                WorkspaceId::new("compensated").unwrap(),
25403                created_target.to_string_lossy().into_owned(),
25404                Some("main".to_owned()),
25405            )
25406            .await
25407            .unwrap_err();
25408        assert_eq!(
25409            created_recovery.code,
25410            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25411        );
25412        assert!(created_target.join(".git").is_dir());
25413        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25414            workspace.workspace_id.as_str() != "compensated"
25415        }));
25416
25417        let existing_recovery = shared
25418            .create_standalone_workspace(
25419                WorkspaceId::new("restored").unwrap(),
25420                existing_target.to_string_lossy().into_owned(),
25421                None,
25422            )
25423            .await
25424            .unwrap_err();
25425        assert_eq!(
25426            existing_recovery.code,
25427            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25428        );
25429        assert!(existing_target.is_dir());
25430        assert!(existing_target.join(".git").is_dir());
25431        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25432            workspace.workspace_id.as_str() != "restored"
25433        }));
25434
25435        let registered_empty = fixture_root.join("registered-empty");
25436        std::fs::create_dir(&registered_empty).unwrap();
25437        let catalog = active_registry().unwrap();
25438        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25439        let registered = WorkspaceConfig::new(
25440            WorkspaceId::new("registered-empty").unwrap(),
25441            &registered_empty,
25442        )
25443        .unwrap();
25444        let duplicate_shared = NodeShared::new(
25445            handle,
25446            "fixture-token".to_owned(),
25447            NodeId::new("node-standalone-duplicate-root").unwrap(),
25448            vec![registered],
25449            vec![agent("claude")],
25450        );
25451        let duplicate = duplicate_shared
25452            .create_standalone_workspace(
25453                WorkspaceId::new("duplicate-root").unwrap(),
25454                registered_empty.to_string_lossy().into_owned(),
25455                None,
25456            )
25457            .await
25458            .unwrap_err();
25459        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25460        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25461        std::fs::remove_dir_all(fixture_root).unwrap();
25462    }
25463
25464    #[test]
25465    fn managed_record_id_collision_preserves_the_existing_record() {
25466        let catalog = active_registry().unwrap();
25467        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25468        let workspace = WorkspaceConfig::new(
25469            WorkspaceId::new("primary").unwrap(),
25470            std::env::current_dir().unwrap(),
25471        )
25472        .unwrap();
25473        let shared = NodeShared::new(
25474            handle,
25475            "fixture-token".to_owned(),
25476            NodeId::new("node-1").unwrap(),
25477            vec![workspace.clone()],
25478            vec![agent("claude")],
25479        );
25480        let record_id = SessionRecordId::new("sr-collision").unwrap();
25481        let original = ManagedSessionRecord {
25482            record_id: record_id.clone(),
25483            display_name: "original".to_owned(),
25484            provider: agent("claude"),
25485            mode: SessionMode::Pty,
25486            state: ManagedSessionState::Dormant,
25487            workspace_id: workspace.workspace_id,
25488            canonical_root: opaque_windows_path(workspace.canonical_root),
25489            provider_session: None,
25490            active_session: None,
25491            environment_profile: None,
25492            bundle: None,
25493            context_id: None,
25494            context: None,
25495            exported_context: None,
25496            task_binding: None,
25497            created_at_unix_ms: 1,
25498            updated_at_unix_ms: 1,
25499            last_error: None,
25500        };
25501        shared.insert_record(original.clone()).unwrap();
25502        let mut collision = original.clone();
25503        collision.display_name = "replacement".to_owned();
25504        let error = shared.insert_record(collision).unwrap_err();
25505        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25506        assert_eq!(shared.record(&record_id).unwrap(), original);
25507    }
25508
25509    #[test]
25510    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25511        let catalog = active_registry().unwrap();
25512        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25513        let workspace = WorkspaceConfig::new(
25514            WorkspaceId::new("primary").unwrap(),
25515            std::env::current_dir().unwrap(),
25516        )
25517        .unwrap();
25518        let shared = NodeShared::new(
25519            handle,
25520            "fixture-token".to_owned(),
25521            NodeId::new("node-1").unwrap(),
25522            vec![workspace.clone()],
25523            vec![agent("claude")],
25524        );
25525        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25526        shared
25527            .insert_record(ManagedSessionRecord {
25528                record_id: record_id.clone(),
25529                display_name: "runtime error".to_owned(),
25530                provider: agent("claude"),
25531                mode: SessionMode::Pty,
25532                state: ManagedSessionState::IdentityPending,
25533                workspace_id: workspace.workspace_id,
25534                canonical_root: opaque_windows_path(workspace.canonical_root),
25535                provider_session: None,
25536                active_session: None,
25537                environment_profile: None,
25538                bundle: None,
25539                context_id: None,
25540                context: None,
25541                exported_context: None,
25542                task_binding: None,
25543                created_at_unix_ms: 1,
25544                updated_at_unix_ms: 1,
25545                last_error: None,
25546            })
25547            .unwrap();
25548
25549        shared
25550            .mark_record_error(
25551                &record_id,
25552                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25553            )
25554            .unwrap();
25555        let record = shared.record(&record_id).unwrap();
25556        assert_eq!(record.state, ManagedSessionState::Unavailable);
25557        assert_eq!(
25558            record.last_error.as_deref(),
25559            Some("provider-runtime-failed"),
25560        );
25561        let snapshot = shared.snapshot();
25562        assert_eq!(snapshot.session_records, vec![record]);
25563    }
25564
25565    /// Regression coverage for the "operator sees `deadline` instead of the
25566    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25567    /// able to learn a command's asynchronous kernel rejection (here,
25568    /// specifically classified as `UnsupportedTransport`) exactly once, and
25569    /// must not still find it after that -- proving the fast path this
25570    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25571    /// loop) drains the reason instead of leaving it to be rediscovered (or
25572    /// missed) on a later poll.
25573    #[test]
25574    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25575        let catalog = active_registry().unwrap();
25576        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25577        let workspace = WorkspaceConfig::new(
25578            WorkspaceId::new("primary").unwrap(),
25579            std::env::current_dir().unwrap(),
25580        )
25581        .unwrap();
25582        let shared = NodeShared::new(
25583            handle,
25584            "fixture-token".to_owned(),
25585            NodeId::new("node-1").unwrap(),
25586            vec![workspace],
25587            vec![agent("claude")],
25588        );
25589
25590        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25591
25592        shared.record_rejected_command(
25593            CommandId(1),
25594            "agent 'claude' does not support transport Acp".to_owned(),
25595            true,
25596        );
25597        // An unrelated command_id must not see another command's reason.
25598        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25599
25600        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25601        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25602        assert!(reason.unsupported_transport);
25603
25604        // Consumed: a second read for the same command_id finds nothing.
25605        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25606
25607        // A rejection unrelated to transport support keeps its own
25608        // classification distinct, so the reader can choose the generic
25609        // `BackendOperationFailed` code instead of misreporting
25610        // `UnsupportedTransport`.
25611        shared.record_rejected_command(
25612            CommandId(3),
25613            "backend busy".to_owned(),
25614            false,
25615        );
25616        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25617        assert!(!unclassified.unsupported_transport);
25618    }
25619
25620    #[test]
25621    fn provider_identity_update_is_ignored_without_exact_session_policy() {
25622        let catalog = active_registry().unwrap();
25623        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25624        let workspace = WorkspaceConfig::new(
25625            WorkspaceId::new("primary").unwrap(),
25626            std::env::current_dir().unwrap(),
25627        )
25628        .unwrap();
25629        let shared = NodeShared::new(
25630            handle,
25631            "fixture-token".to_owned(),
25632            NodeId::new("node-identity-policy").unwrap(),
25633            vec![workspace.clone()],
25634            vec![agent("claude")],
25635        );
25636        let address = SessionAddress {
25637            workspace_id: workspace.workspace_id.clone(),
25638            session: SessionKey {
25639                instance_id: AgentInstanceId(90),
25640                generation: SessionGeneration(1),
25641            },
25642        };
25643        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25644        shared
25645            .insert_record(ManagedSessionRecord {
25646                record_id: record_id.clone(),
25647                display_name: "policy denied".to_owned(),
25648                provider: agent("claude"),
25649                mode: SessionMode::Pty,
25650                state: ManagedSessionState::IdentityPending,
25651                workspace_id: workspace.workspace_id,
25652                canonical_root: opaque_windows_path(workspace.canonical_root),
25653                provider_session: None,
25654                active_session: Some(address.clone()),
25655                environment_profile: None,
25656                bundle: None,
25657                context_id: None,
25658                context: None,
25659                exported_context: None,
25660                task_binding: None,
25661                created_at_unix_ms: 1,
25662                updated_at_unix_ms: 1,
25663                last_error: None,
25664            })
25665            .unwrap();
25666        let event = ControlEvent {
25667            sequence: 1,
25668            command_id: None,
25669            instance_id: address.session.instance_id,
25670            generation: address.session.generation,
25671            event: ControlEventKind::ProviderEvent {
25672                sequence: 1,
25673                source: gate4agent_types::ProviderSource {
25674                    family: AdapterFamily::PtySemantic,
25675                    binding: AdapterBinding::new(
25676                        gate4agent_types::AdapterId::new("claude").unwrap(),
25677                        "fixture/v1",
25678                        gate4agent_types::AdapterVerification::SyntheticFixture,
25679                    )
25680                    .unwrap(),
25681                },
25682                source_sequence: 1,
25683                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25684                    identity: gate4agent_types::ProviderSessionIdentity {
25685                        key: gate4agent_types::ProviderSessionKey::SessionId,
25686                        id: "provider-session".to_owned(),
25687                        transcript_path: None,
25688                    },
25689                },
25690            },
25691        };
25692
25693        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25694
25695        let record = shared.record(&record_id).unwrap();
25696        assert!(record.provider_session.is_none());
25697        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25698        assert_eq!(record.active_session.as_ref(), Some(&address));
25699    }
25700
25701    #[test]
25702    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25703        let secondary_root = temporary_workspace_root("identity-scope");
25704        std::fs::create_dir_all(&secondary_root).unwrap();
25705        let catalog = active_registry().unwrap();
25706        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25707        let primary = WorkspaceConfig::new(
25708            WorkspaceId::new("primary").unwrap(),
25709            std::env::current_dir().unwrap(),
25710        )
25711        .unwrap();
25712        let secondary = WorkspaceConfig::new(
25713            WorkspaceId::new("secondary").unwrap(),
25714            &secondary_root,
25715        )
25716        .unwrap();
25717        let shared = NodeShared::new(
25718            handle,
25719            "fixture-token".to_owned(),
25720            NodeId::new("node-1").unwrap(),
25721            vec![primary.clone(), secondary.clone()],
25722            vec![agent("claude")],
25723        );
25724        let identity = gate4agent_types::ProviderSessionIdentity {
25725            key: gate4agent_types::ProviderSessionKey::SessionId,
25726            id: "provider-session".to_owned(),
25727            transcript_path: None,
25728        };
25729        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25730        shared
25731            .insert_record(ManagedSessionRecord {
25732                record_id: dormant_id.clone(),
25733                display_name: "dormant".to_owned(),
25734                provider: agent("claude"),
25735                mode: SessionMode::Pty,
25736                state: ManagedSessionState::Dormant,
25737                workspace_id: primary.workspace_id,
25738                canonical_root: opaque_windows_path(primary.canonical_root),
25739                provider_session: Some(identity.clone()),
25740                active_session: None,
25741                environment_profile: None,
25742                bundle: None,
25743                context_id: None,
25744                context: None,
25745                exported_context: None,
25746                task_binding: None,
25747                created_at_unix_ms: 1,
25748                updated_at_unix_ms: 1,
25749                last_error: None,
25750            })
25751            .unwrap();
25752        let address = SessionAddress {
25753            workspace_id: secondary.workspace_id.clone(),
25754            session: SessionKey {
25755                instance_id: AgentInstanceId(91),
25756                generation: SessionGeneration(1),
25757            },
25758        };
25759        let current_id = SessionRecordId::new("sr-current").unwrap();
25760        shared
25761            .insert_record(ManagedSessionRecord {
25762                record_id: current_id.clone(),
25763                display_name: "current".to_owned(),
25764                provider: agent("claude"),
25765                mode: SessionMode::Pty,
25766                state: ManagedSessionState::IdentityPending,
25767                workspace_id: secondary.workspace_id,
25768                canonical_root: opaque_windows_path(secondary.canonical_root),
25769                provider_session: None,
25770                active_session: Some(address.clone()),
25771                environment_profile: None,
25772                bundle: None,
25773                context_id: None,
25774                context: None,
25775                exported_context: None,
25776                task_binding: None,
25777                created_at_unix_ms: 2,
25778                updated_at_unix_ms: 2,
25779                last_error: None,
25780            })
25781            .unwrap();
25782        shared.bind_managed_session(
25783            &address,
25784            current_id.clone(),
25785            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25786            None,
25787        );
25788        let event = ControlEvent {
25789            sequence: 1,
25790            command_id: None,
25791            instance_id: address.session.instance_id,
25792            generation: address.session.generation,
25793            event: ControlEventKind::ProviderEvent {
25794                sequence: 1,
25795                source: gate4agent_types::ProviderSource {
25796                    family: gate4agent_types::AdapterFamily::PtySemantic,
25797                    binding: gate4agent_types::AdapterBinding::new(
25798                        gate4agent_types::AdapterId::new("claude").unwrap(),
25799                        "fixture/v1",
25800                        gate4agent_types::AdapterVerification::SyntheticFixture,
25801                    )
25802                    .unwrap(),
25803                },
25804                source_sequence: 1,
25805                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25806                    identity,
25807                },
25808            },
25809        };
25810        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
25811
25812        let binding = shared
25813            .session_bindings
25814            .lock()
25815            .unwrap_or_else(|poisoned| poisoned.into_inner())
25816            .get(&address.session.instance_id)
25817            .cloned()
25818            .unwrap();
25819        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
25820        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25821        let current = shared.record(&current_id).unwrap();
25822        assert_eq!(current.state, ManagedSessionState::Unavailable);
25823        assert_eq!(current.active_session.as_ref(), Some(&address));
25824        assert_eq!(
25825            current.last_error.as_deref(),
25826            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25827        );
25828        std::fs::remove_dir_all(secondary_root).unwrap();
25829    }
25830
25831    #[tokio::test]
25832    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25833        let root = temporary_workspace_root("semantic-provider-session");
25834        std::fs::create_dir_all(&root).unwrap();
25835        let state_path = root.join("state-v1.json");
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            &root,
25841        )
25842        .unwrap();
25843        let node_id = NodeId::new("node-semantic-identity").unwrap();
25844        let shared = NodeShared::new_with_incarnation(
25845            handle,
25846            "fixture-token".to_owned(),
25847            node_id.clone(),
25848            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25849            vec![workspace.clone()],
25850            vec![agent("claude")],
25851            ProviderRuntimeStatuses::default(),
25852            None,
25853            Vec::new(),
25854            Vec::new(),
25855            SpawnProfileRegistry::default(),
25856            None,
25857            None,
25858            None,
25859            Some(state_path.clone()),
25860            Vec::new(),
25861            Vec::new(),
25862            Vec::new(),
25863            Vec::new(),
25864            None,
25865            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25866        );
25867        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25868            key: gate4agent_types::ProviderSessionKey::SessionId,
25869            id: "provider-session".to_owned(),
25870            transcript_path: None,
25871        };
25872        let durable_id = SessionRecordId::new("sr-durable").unwrap();
25873        shared
25874            .insert_record(ManagedSessionRecord {
25875                record_id: durable_id.clone(),
25876                display_name: "durable".to_owned(),
25877                provider: agent("claude"),
25878                mode: SessionMode::Pty,
25879                state: ManagedSessionState::Dormant,
25880                workspace_id: workspace.workspace_id.clone(),
25881                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25882                provider_session: Some(persisted_identity.clone()),
25883                active_session: None,
25884                environment_profile: None,
25885                bundle: None,
25886                context_id: None,
25887                context: None,
25888                exported_context: None,
25889                task_binding: None,
25890                created_at_unix_ms: 1,
25891                updated_at_unix_ms: 1,
25892                last_error: None,
25893            })
25894            .unwrap();
25895        let address = SessionAddress {
25896            workspace_id: workspace.workspace_id.clone(),
25897            session: SessionKey {
25898                instance_id: AgentInstanceId(92),
25899                generation: SessionGeneration(2),
25900            },
25901        };
25902        let pending_id = SessionRecordId::new("sr-pending").unwrap();
25903        shared
25904            .insert_record(ManagedSessionRecord {
25905                record_id: pending_id.clone(),
25906                display_name: "pending".to_owned(),
25907                provider: agent("claude"),
25908                mode: SessionMode::Pty,
25909                state: ManagedSessionState::IdentityPending,
25910                workspace_id: workspace.workspace_id.clone(),
25911                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25912                provider_session: None,
25913                active_session: Some(address.clone()),
25914                environment_profile: None,
25915                bundle: None,
25916                context_id: None,
25917                context: None,
25918                exported_context: None,
25919                task_binding: None,
25920                created_at_unix_ms: 2,
25921                updated_at_unix_ms: 2,
25922                last_error: None,
25923            })
25924            .unwrap();
25925        shared.bind_managed_session(
25926            &address,
25927            pending_id.clone(),
25928            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25929            None,
25930        );
25931        let observed_identity = gate4agent_types::ProviderSessionIdentity {
25932            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25933            ..persisted_identity
25934        };
25935        let event = ControlEvent {
25936            sequence: 1,
25937            command_id: None,
25938            instance_id: address.session.instance_id,
25939            generation: address.session.generation,
25940            event: ControlEventKind::ProviderEvent {
25941                sequence: 1,
25942                source: gate4agent_types::ProviderSource {
25943                    family: gate4agent_types::AdapterFamily::PtySemantic,
25944                    binding: gate4agent_types::AdapterBinding::new(
25945                        gate4agent_types::AdapterId::new("claude").unwrap(),
25946                        "fixture/v1",
25947                        gate4agent_types::AdapterVerification::SyntheticFixture,
25948                    )
25949                    .unwrap(),
25950                },
25951                source_sequence: 1,
25952                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25953                    identity: observed_identity.clone(),
25954                },
25955            },
25956        };
25957        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25958
25959        let snapshot = shared.snapshot();
25960        assert_eq!(snapshot.session_records.len(), 1);
25961        let live = &snapshot.session_records[0];
25962        assert_eq!(live.record_id, durable_id);
25963        assert_eq!(live.state, ManagedSessionState::Live);
25964        assert_eq!(live.active_session.as_ref(), Some(&address));
25965        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25966        let binding = shared
25967            .session_bindings
25968            .lock()
25969            .unwrap_or_else(|poisoned| poisoned.into_inner())
25970            .get(&address.session.instance_id)
25971            .cloned()
25972            .unwrap();
25973        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25974
25975        let mut duplicate = live.clone();
25976        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25977        duplicate.display_name = "duplicate".to_owned();
25978        duplicate.state = ManagedSessionState::Dormant;
25979        duplicate.active_session = None;
25980        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25981        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25982        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25983        assert_eq!(shared.snapshot().session_records.len(), 1);
25984
25985        let terminal_size = gate4agent_types::TerminalSize {
25986            rows: 24,
25987            columns: 80,
25988        };
25989        let (first_busy, second_busy) = tokio::join!(
25990            shared.resume_session_record(&durable_id, terminal_size, None),
25991            shared.resume_session_record(&durable_id, terminal_size, None),
25992        );
25993        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25994            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25995            assert_eq!(busy.message, "session-record-busy");
25996        }
25997
25998        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25999        assert_eq!(loaded.records.len(), 1);
26000        assert_eq!(loaded.records[0].record_id, durable_id);
26001        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26002        assert_eq!(
26003            loaded.records[0]
26004                .provider_session
26005                .as_ref()
26006                .unwrap()
26007                .transcript_path,
26008            None,
26009        );
26010        let catalog = active_registry().unwrap();
26011        let (reloaded_handle, _reloaded_runtime) =
26012            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26013        let reloaded = NodeShared::new_with_incarnation(
26014            reloaded_handle,
26015            "fixture-token".to_owned(),
26016            node_id,
26017            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26018            vec![workspace],
26019            vec![agent("claude")],
26020            ProviderRuntimeStatuses::default(),
26021            None,
26022            Vec::new(),
26023            Vec::new(),
26024            SpawnProfileRegistry::default(),
26025            None,
26026            None,
26027            None,
26028            None,
26029            loaded.records,
26030            loaded.managed_worktrees,
26031            loaded.managed_worktree_tombstones,
26032            loaded.materializations,
26033            loaded.warning,
26034            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26035        );
26036        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26037        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26038        std::fs::remove_dir_all(root).unwrap();
26039    }
26040
26041    #[tokio::test]
26042    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26043        let secondary_root = temporary_workspace_root("workspace-restore");
26044        let other_root = temporary_workspace_root("workspace-conflict");
26045        std::fs::create_dir_all(&secondary_root).unwrap();
26046        std::fs::create_dir_all(&other_root).unwrap();
26047        let catalog = active_registry().unwrap();
26048        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26049        let primary = WorkspaceConfig::new(
26050            WorkspaceId::new("primary").unwrap(),
26051            std::env::current_dir().unwrap(),
26052        )
26053        .unwrap();
26054        let secondary = WorkspaceConfig::new(
26055            WorkspaceId::new("secondary").unwrap(),
26056            &secondary_root,
26057        )
26058        .unwrap();
26059        let shared = NodeShared::new(
26060            handle,
26061            "fixture-token".to_owned(),
26062            NodeId::new("node-1").unwrap(),
26063            vec![primary, secondary.clone()],
26064            vec![agent("claude")],
26065        );
26066        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26067        shared
26068            .insert_record(ManagedSessionRecord {
26069                record_id: record_id.clone(),
26070                display_name: "workspace record".to_owned(),
26071                provider: agent("claude"),
26072                mode: SessionMode::Pty,
26073                state: ManagedSessionState::Dormant,
26074                workspace_id: secondary.workspace_id.clone(),
26075                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26076                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26077                    key: gate4agent_types::ProviderSessionKey::SessionId,
26078                    id: "provider-session".to_owned(),
26079                    transcript_path: None,
26080                }),
26081                active_session: None,
26082                environment_profile: None,
26083                bundle: None,
26084                context_id: None,
26085                context: None,
26086                exported_context: None,
26087                task_binding: None,
26088                created_at_unix_ms: 1,
26089                updated_at_unix_ms: 1,
26090                last_error: None,
26091            })
26092            .unwrap();
26093        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26094        let unavailable = shared.record(&record_id).unwrap();
26095        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26096        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26097
26098        let conflict = shared
26099            .register_workspace(
26100                secondary.workspace_id.clone(),
26101                other_root.to_string_lossy().into_owned(),
26102            )
26103            .await
26104            .unwrap_err();
26105        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26106        shared
26107            .register_workspace(
26108                secondary.workspace_id,
26109                secondary_root.to_string_lossy().into_owned(),
26110            )
26111            .await
26112            .unwrap();
26113        let restored = shared.record(&record_id).unwrap();
26114        assert_eq!(restored.state, ManagedSessionState::Dormant);
26115        assert!(restored.last_error.is_none());
26116        std::fs::remove_dir_all(secondary_root).unwrap();
26117        std::fs::remove_dir_all(other_root).unwrap();
26118    }
26119
26120    #[test]
26121    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26122        let error = validate_workspace_request_root(
26123            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26124        )
26125        .unwrap_err();
26126        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26127        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26128        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26129    }
26130
26131    #[test]
26132    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26133        let catalog = active_registry().unwrap();
26134        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26135        let workspace = WorkspaceConfig::new(
26136            WorkspaceId::new("primary").unwrap(),
26137            std::env::current_dir().unwrap(),
26138        )
26139        .unwrap();
26140        let shared = NodeShared::new(
26141            handle,
26142            "fixture-token".to_owned(),
26143            NodeId::new("node-1").unwrap(),
26144            vec![workspace],
26145            vec![agent("claude")],
26146        );
26147        let publishers = 4_u64;
26148        let events_per_publisher = 256_u64;
26149        let start = std::sync::Barrier::new(publishers as usize + 1);
26150        let finished = AtomicU64::new(0);
26151
26152        std::thread::scope(|scope| {
26153            for _ in 0..publishers {
26154                scope.spawn(|| {
26155                    start.wait();
26156                    for _ in 0..events_per_publisher {
26157                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26158                    }
26159                    finished.fetch_add(1, Ordering::Release);
26160                });
26161            }
26162            start.wait();
26163            while finished.load(Ordering::Acquire) != publishers {
26164                assert_resync_history_is_linearized(shared.resync(0));
26165                std::thread::yield_now();
26166            }
26167        });
26168        let response = shared.resync(0);
26169        assert_resync_history_is_linearized(response.clone());
26170        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26171            unreachable!("resync helper returned another response");
26172        };
26173        assert_eq!(event_sequence, publishers * events_per_publisher);
26174        assert_eq!(events.len() as u64, event_sequence);
26175    }
26176
26177    fn assert_resync_history_is_linearized(response: NodeResponse) {
26178        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26179            unreachable!("resync helper returned another response");
26180        };
26181        assert!(events.windows(2).all(|pair| {
26182            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26183        }));
26184        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26185    }
26186
26187    #[test]
26188    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26189        assert_eq!(
26190            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26191            r"C:\repo\worktree",
26192        );
26193        assert_eq!(
26194            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26195            r"\\server\share\repo",
26196        );
26197        assert!(validate_workspace_root(
26198            &WorkspaceId::new("network").unwrap(),
26199            r"\\server\share\repo",
26200        )
26201        .is_err());
26202        let workspace = WorkspaceConfig::new(
26203            WorkspaceId::new("primary").unwrap(),
26204            std::env::current_dir().unwrap(),
26205        )
26206        .unwrap();
26207        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26208    }
26209
26210    #[test]
26211    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26212        let catalog = active_registry().unwrap();
26213        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26214        let workspace_id = WorkspaceId::new("primary").unwrap();
26215        let workspace = WorkspaceConfig::new(
26216            workspace_id.clone(),
26217            std::env::current_dir().unwrap(),
26218        )
26219        .unwrap();
26220        let shared = NodeShared::new(
26221            handle,
26222            "fixture-token".to_owned(),
26223            NodeId::new("node-1").unwrap(),
26224            vec![workspace],
26225            vec![agent("claude")],
26226        );
26227        let instance_id = AgentInstanceId(17);
26228        let original = SessionAddress {
26229            workspace_id,
26230            session: SessionKey {
26231                instance_id,
26232                generation: SessionGeneration(3),
26233            },
26234        };
26235        shared.bind_session(
26236            instance_id,
26237            original.workspace_id.clone(),
26238            original.session.generation,
26239        );
26240        shared
26241            .arm_resume(
26242                &original,
26243                CommandId(41),
26244                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26245            )
26246            .unwrap();
26247
26248        shared.publish_control(ControlEvent {
26249            sequence: 1,
26250            command_id: None,
26251            instance_id,
26252            generation: SessionGeneration(4),
26253            event: ControlEventKind::Running { process_id: None },
26254        });
26255        assert_eq!(
26256            shared.address_for(instance_id, SessionGeneration(3)),
26257            Some(original.clone()),
26258        );
26259        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26260
26261        shared.publish_control(ControlEvent {
26262            sequence: 2,
26263            command_id: Some(CommandId(42)),
26264            instance_id,
26265            generation: SessionGeneration(3),
26266            event: ControlEventKind::CommandRejected {
26267                message: "unrelated command".to_owned(),
26268            },
26269        });
26270
26271        shared.publish_control(ControlEvent {
26272            sequence: 3,
26273            command_id: None,
26274            instance_id,
26275            generation: SessionGeneration(4),
26276            event: ControlEventKind::ResumeAuthorized {
26277                session: gate4agent_types::ResumeSessionSummary {
26278                    key: gate4agent_types::ProviderSessionKey::SessionId,
26279                    id: "fixture-session".to_owned(),
26280                },
26281            },
26282        });
26283        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26284        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26285        assert_eq!(rebound.workspace_id, original.workspace_id);
26286        let history = shared
26287            .history
26288            .lock()
26289            .unwrap_or_else(|poisoned| poisoned.into_inner());
26290        assert!(history.events.iter().any(|envelope| matches!(
26291            &envelope.event,
26292            NodeEvent::Control { address, event }
26293                if address == &rebound
26294                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26295        )));
26296    }
26297
26298    #[test]
26299    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26300        for agent in ["claude", "codex", "kimi"] {
26301            assert_eq!(
26302                prompt_framing(&AgentId::new(agent).unwrap()),
26303                PromptFraming::BracketedPaste
26304            );
26305        }
26306    }
26307
26308    fn generous_walk_deadline() -> Instant {
26309        Instant::now() + Duration::from_secs(30)
26310    }
26311
26312    #[test]
26313    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26314        let root = temporary_workspace_root("tree");
26315        std::fs::create_dir_all(root.join("src")).unwrap();
26316        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26317        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26318        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26319        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26320        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26321        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26322        // real repository's own build layout) that carries the Cache
26323        // Directory Tagging Specification marker cargo writes into every
26324        // `--target-dir` it creates: must be skipped even though its name
26325        // does not exactly match `target`.
26326        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26327        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26328        // A directory that merely *starts with* `target` but carries no
26329        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26330        // on the name alone, so this stays in the response.
26331        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26332        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26333
26334        let (entries, truncated, _budget) =
26335            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26336        assert!(!truncated);
26337        assert!(entries.iter().any(|entry| {
26338            entry.relative_path.as_utf8() == Some("src")
26339                && entry.kind == WorkspaceEntryKind::Directory
26340        }));
26341        assert!(entries.iter().any(|entry| {
26342            entry.relative_path.as_utf8() == Some("src/lib.rs")
26343                && entry.kind == WorkspaceEntryKind::File
26344        }));
26345        assert!(entries.iter().any(|entry| {
26346            entry.relative_path.as_utf8() == Some("target-notes")
26347                && entry.kind == WorkspaceEntryKind::Directory
26348        }));
26349        assert!(entries.iter().any(|entry| {
26350            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26351                && entry.kind == WorkspaceEntryKind::File
26352        }));
26353        assert!(entries.iter().all(|entry| {
26354            let path = entry.relative_path.as_utf8().unwrap();
26355            !path.starts_with(".git")
26356                && !(path == "target" || path.starts_with("target/"))
26357                && !(path == "target-a2" || path.starts_with("target-a2/"))
26358                && !path.starts_with("node_modules")
26359        }));
26360
26361        std::fs::remove_dir_all(&root).unwrap();
26362    }
26363
26364    #[test]
26365    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26366        let root = temporary_workspace_root("tree-entry-cap");
26367        std::fs::create_dir_all(&root).unwrap();
26368        for index in 0..20 {
26369            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26370        }
26371
26372        let (entries, truncated, budget) =
26373            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26374        assert!(truncated);
26375        assert!(budget.entry_cap_exceeded);
26376        assert!(!budget.time_budget_exceeded);
26377        assert!(entries.len() <= 5);
26378
26379        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26380            .expect("an entry-cap-truncated walk must report a truncation marker");
26381        assert!(inspection_truncation.walk_entry_cap_exceeded);
26382        assert!(!inspection_truncation.walk_time_budget_exceeded);
26383        assert!(!inspection_truncation.git_time_budget_exceeded);
26384        assert!(inspection_truncation.entries_visited >= 5);
26385
26386        std::fs::remove_dir_all(&root).unwrap();
26387    }
26388
26389    #[test]
26390    fn workspace_walk_within_budget_reports_no_truncation() {
26391        let root = temporary_workspace_root("tree-no-truncation");
26392        std::fs::create_dir_all(&root).unwrap();
26393        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26394
26395        let (_entries, truncated, budget) =
26396            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26397        assert!(!truncated);
26398        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26399
26400        std::fs::remove_dir_all(&root).unwrap();
26401    }
26402
26403    #[test]
26404    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26405        let root = temporary_workspace_root("tree-prefix-siblings");
26406        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26407        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26408        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26409        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26410
26411        let (entries, truncated, _budget) =
26412            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26413        assert!(!truncated);
26414        let paths = entries
26415            .iter()
26416            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26417            .collect::<Vec<_>>();
26418        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26419        assert_eq!(
26420            paths,
26421            vec![
26422                "gate4agent-c2".to_owned(),
26423                "gate4agent-c2-client".to_owned(),
26424                "gate4agent-c2-client/lib.rs".to_owned(),
26425                "gate4agent-c2/src".to_owned(),
26426                "gate4agent-c2/src/lib.rs".to_owned(),
26427            ],
26428        );
26429
26430        std::fs::remove_dir_all(&root).unwrap();
26431    }
26432
26433    #[test]
26434    fn git_parsers_cap_status_and_commit_results() {
26435        let mut status_output = Vec::new();
26436        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26437            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26438        }
26439        let mut snapshot = git_snapshot_for_parser();
26440        parse_git_status(&status_output, &mut snapshot);
26441        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26442        assert!(snapshot.truncated);
26443        assert_eq!(snapshot.status[0].index_status, " ");
26444        assert_eq!(snapshot.status[0].worktree_status, "M");
26445
26446        snapshot.truncated = false;
26447        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26448            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26449            .collect::<String>();
26450        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26451        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26452        assert!(snapshot.truncated);
26453    }
26454
26455    #[test]
26456    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26457        let mut snapshot = git_snapshot_for_parser();
26458        parse_git_status(
26459            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26460            &mut snapshot,
26461        );
26462
26463        assert!(!snapshot.truncated);
26464        assert_eq!(snapshot.status.len(), 3);
26465        assert_eq!(snapshot.status[0].index_status, "R");
26466        assert_eq!(snapshot.status[0].worktree_status, " ");
26467        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26468        assert_eq!(
26469            snapshot.status[0]
26470                .previous_path
26471                .as_ref()
26472                .and_then(RepositoryPath::as_utf8),
26473            Some("src/previous name.rs"),
26474        );
26475        assert_eq!(snapshot.status[1].index_status, " ");
26476        assert_eq!(snapshot.status[1].worktree_status, "C");
26477        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26478        assert_eq!(
26479            snapshot.status[1]
26480                .previous_path
26481                .as_ref()
26482                .and_then(RepositoryPath::as_utf8),
26483            Some("src/original.rs"),
26484        );
26485        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26486        assert!(snapshot.status[2].previous_path.is_none());
26487    }
26488
26489    #[test]
26490    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26491        let mut snapshot = git_snapshot_for_parser();
26492        parse_git_status(
26493            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26494            &mut snapshot,
26495        );
26496
26497        assert!(snapshot.truncated);
26498        assert_eq!(snapshot.status.len(), 1);
26499        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26500        assert!(snapshot.status[0].previous_path.is_none());
26501    }
26502
26503    #[tokio::test]
26504    async fn observer_can_browse_host_directories_without_controller() {
26505        let root = temporary_workspace_root("host-directory-observer");
26506        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26507        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26508        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26509        let workspace_id = WorkspaceId::new("primary").unwrap();
26510        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26511        let catalog = active_registry().unwrap();
26512        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26513        let shared = NodeShared::new(
26514            handle,
26515            "fixture-token".to_owned(),
26516            NodeId::new("node-1").unwrap(),
26517            vec![workspace],
26518            vec![agent("claude")],
26519        );
26520        let request = NodeRequest::BrowseHostDirectories {
26521            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26522            after: None,
26523        };
26524        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26525            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26526        }));
26527        assert!(!request_uses_unnegotiated_capability(
26528            &request,
26529            &baseline_capabilities().unwrap(),
26530        ));
26531        assert!(request_uses_unnegotiated_capability(&request, &[]));
26532
26533        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26534            .await
26535            .unwrap();
26536        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26537            panic!("host directory browse returned another response");
26538        };
26539        assert_eq!(
26540            listing
26541                .entries
26542                .iter()
26543                .map(|entry| entry.display_name.as_str())
26544                .collect::<Vec<_>>(),
26545            vec!["directory-a", "directory-b"],
26546        );
26547        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26548        assert!(!listing.incomplete);
26549        std::fs::remove_dir_all(root).unwrap();
26550    }
26551
26552    #[tokio::test]
26553    async fn observer_can_inspect_only_a_registered_workspace() {
26554        let root = temporary_workspace_root("observer");
26555        std::fs::create_dir_all(root.join("src")).unwrap();
26556        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26557        let workspace_id = WorkspaceId::new("primary").unwrap();
26558        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26559        let catalog = active_registry().unwrap();
26560        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26561        let shared = NodeShared::new(
26562            handle,
26563            "fixture-token".to_owned(),
26564            NodeId::new("node-1").unwrap(),
26565            vec![workspace],
26566            vec![agent("claude")],
26567        );
26568
26569        let response = process_request_inner(
26570            &shared,
26571            77,
26572            ClientRole::Observer,
26573            NodeRequest::InspectWorkspace {
26574                workspace_id: workspace_id.clone(),
26575            },
26576        )
26577        .await
26578        .unwrap();
26579        let NodeResponse::WorkspaceInspected { inspection } = response else {
26580            panic!("workspace inspection returned another response");
26581        };
26582        assert_eq!(inspection.workspace_id, workspace_id);
26583        assert!(inspection.entries.iter().any(|entry| {
26584            entry.relative_path.as_utf8() == Some("src/main.rs")
26585        }));
26586
26587        let unknown = process_request_inner(
26588            &shared,
26589            77,
26590            ClientRole::Observer,
26591            NodeRequest::InspectWorkspace {
26592                workspace_id: WorkspaceId::new("unknown").unwrap(),
26593            },
26594        )
26595        .await
26596        .unwrap_err();
26597        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26598        std::fs::remove_dir_all(&root).unwrap();
26599    }
26600
26601    #[test]
26602    fn context_pack_source_accepts_only_running_or_successful_exit() {
26603        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26604        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26605            exit_code: Some(0),
26606        }));
26607        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26608            exit_code: Some(1),
26609        }));
26610        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26611            exit_code: None,
26612        }));
26613        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26614            message: "provider failed".to_owned(),
26615        }));
26616        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26617        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26618        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26619    }
26620
26621    #[test]
26622    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26623        let git = git_snapshot_for_parser();
26624        let first = ContextPackRepositoryObservation {
26625            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26626            git: git.clone(),
26627            files: Vec::new(),
26628        };
26629        let second = ContextPackRepositoryObservation {
26630            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26631            git,
26632            files: Vec::new(),
26633        };
26634
26635        let error = stable_context_pack_repository(first, second).unwrap_err();
26636
26637        assert_eq!(
26638            error.code,
26639            NodeFailureCode::ContextPackMaterializationFailed,
26640        );
26641    }
26642
26643    #[test]
26644    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26645        let git = git_snapshot_for_parser();
26646        let first = ContextPackRepositoryObservation {
26647            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26648            git: git.clone(),
26649            files: vec![ContextPackRepositoryFileSource::utf8(
26650                "README.md",
26651                "first\n".to_owned(),
26652                6,
26653            )],
26654        };
26655        let second = ContextPackRepositoryObservation {
26656            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26657            git,
26658            files: vec![ContextPackRepositoryFileSource::utf8(
26659                "README.md",
26660                "other\n".to_owned(),
26661                6,
26662            )],
26663        };
26664
26665        let error = stable_context_pack_repository(first, second).unwrap_err();
26666
26667        assert_eq!(
26668            error.code,
26669            NodeFailureCode::ContextPackMaterializationFailed,
26670        );
26671    }
26672
26673    #[tokio::test]
26674    async fn native_session_catalog_zero_active_session_fixture() {
26675        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26676        let history_root = temporary_workspace_root("native-catalog-history");
26677        let outside_root = temporary_workspace_root("native-catalog-outside");
26678        std::fs::create_dir_all(&workspace_root).unwrap();
26679        std::fs::create_dir_all(&history_root).unwrap();
26680        std::fs::create_dir_all(&outside_root).unwrap();
26681        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26682            [
26683                serde_json::json!({
26684                    "type": "user",
26685                    "sessionId": session_id,
26686                    "cwd": cwd.to_str().unwrap(),
26687                    "message": { "content": "raw user text must not cross the catalog wire" }
26688                }),
26689                serde_json::json!({
26690                    "type": "assistant",
26691                    "sessionId": session_id,
26692                    "cwd": cwd.to_str().unwrap(),
26693                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26694                }),
26695                serde_json::json!({
26696                    "type": "ai-title",
26697                    "sessionId": session_id,
26698                    "aiTitle": title
26699                }),
26700            ]
26701            .into_iter()
26702            .map(|record| record.to_string())
26703            .collect::<Vec<_>>()
26704            .join("\n")
26705        };
26706        std::fs::write(
26707            history_root.join("stable-session.jsonl"),
26708            transcript("stable-session", &workspace_root, "Workspace review"),
26709        )
26710        .unwrap();
26711        std::fs::write(
26712            history_root.join("outside-session.jsonl"),
26713            transcript("outside-session", &outside_root, "Outside review"),
26714        )
26715        .unwrap();
26716        let history = NativeHistoryConfig::new(vec![
26717            NativeHistoryRoot::new(
26718                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26719                HistorySourceLayout::SingleNdjson,
26720                history_root.clone(),
26721            )
26722            .unwrap(),
26723        ])
26724        .unwrap();
26725        let catalog = active_registry().unwrap();
26726        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26727        assert!(handle.snapshot().sessions.is_empty());
26728        let workspace_id = WorkspaceId::new("primary").unwrap();
26729        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26730        let mut shared = NodeShared::new(
26731            handle,
26732            "fixture-token".to_owned(),
26733            NodeId::new("node-native-catalog").unwrap(),
26734            vec![workspace],
26735            vec![agent("claude")],
26736        );
26737        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26738            NativeSessionCatalogAuthority::new(history),
26739        )));
26740        let route = NativeSessionCatalogRoute::workspace(
26741            workspace_id.clone(),
26742            agent("claude"),
26743        );
26744        let request = NodeRequest::CatalogNativeSessions {
26745            route: route.clone(),
26746            limit: 2,
26747        };
26748        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26749            .await
26750            .unwrap_err();
26751        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26752        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26753            .await
26754            .unwrap();
26755        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26756            panic!("native catalog returned a different response");
26757        };
26758        let summary = summary.expect("node must emit native session catalog summary metadata");
26759        assert_eq!(entries.len(), 1);
26760        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26761        assert_eq!(entries[0].external_group, None);
26762        assert_eq!(entries[0].record_id, None);
26763        assert_eq!(entries[0].title, None);
26764        assert_eq!(entries[0].model, None);
26765        assert_eq!(entries[0].message_count, 0);
26766        let encoded_entries = serde_json::to_string(&entries).unwrap();
26767        assert!(!encoded_entries.contains("stable-session"));
26768        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26769        let page_request = NodeRequest::PageNativeSessions {
26770            route: route.clone(),
26771            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26772            catalog_revision: summary.catalog_revision,
26773            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26774            after_selection_id: None,
26775            limit: 2,
26776        };
26777        let observer = process_request_inner(
26778            &shared,
26779            7,
26780            ClientRole::Observer,
26781            page_request.clone(),
26782        )
26783        .await
26784        .unwrap_err();
26785        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26786        let page_response = process_request_inner(
26787            &shared,
26788            7,
26789            ClientRole::Operator,
26790            page_request.clone(),
26791        )
26792        .await
26793        .unwrap();
26794        assert!(matches!(
26795            page_response,
26796            NodeResponse::NativeSessionsPaged { page, .. }
26797                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26798        ));
26799        let stale = process_request_inner(
26800            &shared,
26801            7,
26802            ClientRole::Operator,
26803            NodeRequest::PageNativeSessions {
26804                route: route.clone(),
26805                window: crate::protocol::NativeSessionCatalogWindow::Recent,
26806                catalog_revision: summary.catalog_revision.wrapping_add(1),
26807                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26808                after_selection_id: None,
26809                limit: 2,
26810            },
26811        )
26812        .await
26813        .unwrap_err();
26814        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26815        let selection = NativeSessionSelection {
26816            route: route.clone(),
26817            catalog_revision: summary.catalog_revision,
26818            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26819            selection_id: entries[0].selection_id.clone(),
26820        };
26821        let preview_response = process_request_inner(
26822            &shared,
26823            7,
26824            ClientRole::Operator,
26825            NodeRequest::PreviewNativeSession {
26826                selection: selection.clone(),
26827                message_limit: 1,
26828            },
26829        )
26830        .await
26831        .unwrap();
26832        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26833            panic!("native preview returned a different response");
26834        };
26835        assert_eq!(preview.messages.len(), 1);
26836        assert!(preview.message_count_exact);
26837        assert_eq!(preview.messages[0].text, "raw answer");
26838        assert!(preview.truncated);
26839
26840        let index_request = NodeRequest::IndexNativeSession {
26841            selection: selection.clone(),
26842            display_name: "Saved Claude".to_owned(),
26843        };
26844        let observer = process_request_inner(
26845            &shared,
26846            7,
26847            ClientRole::Observer,
26848            index_request.clone(),
26849        )
26850        .await
26851        .unwrap_err();
26852        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26853        let without_controller = process_request_inner(
26854            &shared,
26855            7,
26856            ClientRole::Operator,
26857            index_request.clone(),
26858        )
26859        .await
26860        .unwrap_err();
26861        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26862        let acquired = process_request_inner(
26863            &shared,
26864            7,
26865            ClientRole::Operator,
26866            NodeRequest::AcquireController { lease_ms: 5_000 },
26867        )
26868        .await
26869        .unwrap();
26870        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26871        let indexed = process_request_inner(
26872            &shared,
26873            7,
26874            ClientRole::Operator,
26875            index_request,
26876        )
26877        .await
26878        .unwrap();
26879        assert!(indexed.native_session_index_contract_is_valid());
26880        let NodeResponse::NativeSessionIndexed {
26881            selection: echoed_selection,
26882            record,
26883        } = indexed else {
26884            panic!("native session index returned a different response");
26885        };
26886        assert_eq!(echoed_selection, selection);
26887        let recataloged = process_request_inner(
26888            &shared,
26889            7,
26890            ClientRole::Operator,
26891            NodeRequest::CatalogNativeSessions {
26892                route,
26893                limit: 2,
26894            },
26895        )
26896        .await
26897        .unwrap();
26898        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26899            panic!("native catalog returned a different response after indexing");
26900        };
26901        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26902
26903        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26904        let external_catalog = process_request_inner(
26905            &shared,
26906            7,
26907            ClientRole::Operator,
26908            NodeRequest::CatalogNativeSessions {
26909                route: unregistered_route.clone(),
26910                limit: 2,
26911            },
26912        )
26913        .await
26914        .unwrap();
26915        let external_json = serde_json::to_string(&external_catalog).unwrap();
26916        assert!(!external_json.contains("outside-session"));
26917        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26918        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26919        assert!(!external_json.contains("\"session_id\""));
26920        assert!(!external_json.contains("\"cwd\""));
26921        assert!(!external_json.contains("\"path\""));
26922        let NodeResponse::NativeSessionsCataloged {
26923            route: echoed_route,
26924            entries: external_entries,
26925            summary: Some(external_summary),
26926        } = external_catalog
26927        else {
26928            panic!("unregistered native catalog returned a different response");
26929        };
26930        assert_eq!(echoed_route, unregistered_route);
26931        assert_eq!(external_entries.len(), 1);
26932        assert_eq!(external_entries[0].record_id, None);
26933        let external_group = external_entries[0]
26934            .external_group
26935            .as_ref()
26936            .expect("unregistered session must have an external group");
26937        assert_eq!(
26938            external_group.kind,
26939            gate4agent_types::NativeSessionExternalGroupKind::Project,
26940        );
26941        assert!(external_group.group_id.starts_with("external-"));
26942        assert_eq!(
26943            external_group.display_name,
26944            outside_root.file_name().unwrap().to_string_lossy(),
26945        );
26946        let external_selection = NativeSessionSelection {
26947            route: unregistered_route,
26948            catalog_revision: external_summary.catalog_revision,
26949            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26950            selection_id: external_entries[0].selection_id.clone(),
26951        };
26952        let external_preview = process_request_inner(
26953            &shared,
26954            7,
26955            ClientRole::Operator,
26956            NodeRequest::PreviewNativeSession {
26957                selection: external_selection.clone(),
26958                message_limit: 1,
26959            },
26960        )
26961        .await
26962        .unwrap();
26963        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26964        assert!(!external_preview_json.contains("outside-session"));
26965        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26966        assert!(!external_preview_json.contains("\"session_id\""));
26967        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26968            panic!("unregistered native preview returned a different response");
26969        };
26970        assert_eq!(preview.messages.len(), 1);
26971        let external_index = process_request_inner(
26972            &shared,
26973            7,
26974            ClientRole::Operator,
26975            NodeRequest::IndexNativeSession {
26976                selection: external_selection,
26977                display_name: "External Claude".to_owned(),
26978            },
26979        )
26980        .await
26981        .unwrap_err();
26982        assert_eq!(
26983            external_index.code,
26984            NodeFailureCode::WorkspaceRegistrationRequired,
26985        );
26986        let record_response = process_request_inner(
26987            &shared,
26988            7,
26989            ClientRole::Operator,
26990            NodeRequest::PreviewSessionRecord {
26991                record_id: record.record_id.clone(),
26992                message_limit: 2,
26993            },
26994        )
26995        .await
26996        .unwrap();
26997        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26998            panic!("record preview returned a different response");
26999        };
27000        assert_eq!(record_id, record.record_id);
27001        assert_eq!(preview.messages.len(), 2);
27002        let encoded = serde_json::to_string(&preview).unwrap();
27003        assert!(!encoded.contains("stable-session"));
27004        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27005        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27006            panic!("record preview history summary returned a different response");
27007        };
27008        let summaries = events
27009            .iter()
27010            .filter_map(|envelope| match &envelope.event {
27011                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27012                    if record_id == &record.record_id =>
27013                {
27014                    Some(*summary)
27015                }
27016                _ => None,
27017            })
27018            .collect::<Vec<_>>();
27019        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27020        let summary_json = serde_json::to_string(&summaries).unwrap();
27021        for private in [
27022            "stable-session",
27023            "raw user text must not cross the catalog wire",
27024            "raw answer",
27025            "Workspace review",
27026            "claude-sonnet",
27027        ] {
27028            assert!(!summary_json.contains(private), "{private}");
27029        }
27030        let identity = record.provider_session.clone().unwrap();
27031        assert_eq!(
27032            shared.revalidate_session_record_preview(&record, &identity),
27033            Ok(())
27034        );
27035        shared.remove_record_memory(&record.record_id);
27036        assert_eq!(
27037            shared
27038                .revalidate_session_record_preview(&record, &identity)
27039                .unwrap_err()
27040                .code,
27041            NodeFailureCode::UnknownSessionRecord
27042        );
27043        let released = process_request_inner(
27044            &shared,
27045            7,
27046            ClientRole::Operator,
27047            NodeRequest::ReleaseController,
27048        )
27049        .await
27050        .unwrap();
27051        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27052        assert!(shared.controller_state().is_none());
27053        std::fs::remove_dir_all(workspace_root).unwrap();
27054        std::fs::remove_dir_all(history_root).unwrap();
27055        std::fs::remove_dir_all(outside_root).unwrap();
27056    }
27057
27058    #[tokio::test]
27059    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27060        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27061        std::fs::create_dir_all(&history_root).unwrap();
27062        let history = NativeHistoryConfig::new(vec![
27063            NativeHistoryRoot::new(
27064                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27065                HistorySourceLayout::SingleNdjson,
27066                history_root.clone(),
27067            )
27068            .unwrap(),
27069        ])
27070        .unwrap();
27071        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27072        let held = catalog.lock().unwrap();
27073        let result = timeout(
27074            Duration::from_secs(1),
27075            run_native_session_catalog_operation(
27076                Arc::clone(&catalog),
27077                "native session catalog",
27078                |_| Ok::<(), std::convert::Infallible>(()),
27079            ),
27080        )
27081        .await
27082        .expect("concurrent native catalog request must not wait for the operation deadline")
27083        .unwrap_err();
27084        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27085        drop(held);
27086        std::fs::remove_dir_all(history_root).unwrap();
27087    }
27088
27089    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27090        let unique = std::time::SystemTime::now()
27091            .duration_since(std::time::UNIX_EPOCH)
27092            .unwrap()
27093            .as_nanos();
27094        std::env::temp_dir().join(format!(
27095            "gate4agent-node-{label}-{}-{unique}",
27096            std::process::id(),
27097        ))
27098    }
27099
27100    fn git_snapshot_for_parser() -> GitSnapshot {
27101        GitSnapshot {
27102            is_repository: true,
27103            branch: Some("main".to_owned()),
27104            status: Vec::new(),
27105            recent_commits: Vec::new(),
27106            worktrees: Vec::new(),
27107            managed_worktree: None,
27108            truncated: false,
27109            diagnostic: None,
27110        }
27111    }
27112
27113    #[tokio::test]
27114    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27115        let root = temporary_workspace_root("managed-git-scope");
27116        std::fs::create_dir_all(&root).unwrap();
27117        run_test_git(&root, &["init"]);
27118        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27119        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27120        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27121        run_test_git(&root, &["add", "seed.txt"]);
27122        run_test_git(&root, &["commit", "-m", "seed"]);
27123        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27124
27125        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27126        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27127        let catalog = active_registry().unwrap();
27128        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27129        let shared = NodeShared::new(
27130            handle,
27131            "fixture-token".to_owned(),
27132            NodeId::new("node-1").unwrap(),
27133            vec![workspace],
27134            vec![agent("claude")],
27135        );
27136        let mut lease = ManagedWorktreeLeaseRecord {
27137            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27138            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27139            workspace_id: workspace_id.clone(),
27140            profile_id: WorktreeProfileId::new("default").unwrap(),
27141            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27142            target_root: root.to_string_lossy().into_owned(),
27143            branch: "gate4agent/a".to_owned(),
27144            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27145            expected_head: None,
27146            retention: ManagedWorktreeRetention::Retain,
27147            state: ManagedWorktreeLeaseState::InUse,
27148            session_holders: vec![ManagedWorktreeSessionHolder {
27149                incarnation_id: shared.incarnation_id,
27150                instance_id: AgentInstanceId(7),
27151                generation: SessionGeneration(1),
27152            }],
27153            record_holders: Vec::new(),
27154            cleanup_failure: None,
27155            created_at_unix_ms: 1,
27156            updated_at_unix_ms: 1,
27157        };
27158        *shared
27159            .managed_worktrees
27160            .lock()
27161            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27162            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27163
27164        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27165        let scope = inspection.git.managed_worktree.unwrap();
27166        assert_eq!(scope.lease_id, lease.lease_id);
27167        assert_eq!(scope.branch, "gate4agent/a");
27168        assert_eq!(scope.active_session_count, 1);
27169
27170        lease.branch = "gate4agent/b".to_owned();
27171        *shared
27172            .managed_worktrees
27173            .lock()
27174            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27175            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27176        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27177        assert!(mismatch.git.managed_worktree.is_none());
27178        std::fs::remove_dir_all(root).unwrap();
27179    }
27180
27181    #[tokio::test]
27182    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27183        let root = temporary_workspace_root("workspace-file-write-controller");
27184        std::fs::create_dir_all(root.join("src")).unwrap();
27185        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27186        let workspace_id = WorkspaceId::new("primary").unwrap();
27187        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27188        let catalog = active_registry().unwrap();
27189        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27190        let shared = NodeShared::new(
27191            handle,
27192            "fixture-token".to_owned(),
27193            NodeId::new("node-1").unwrap(),
27194            vec![workspace],
27195            vec![agent("claude")],
27196        );
27197        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27198        let expected_revision = workspace_file_revision(b"old\n");
27199        let request = NodeRequest::WriteWorkspaceFile {
27200            workspace_id: workspace_id.clone(),
27201            path: path.clone(),
27202            expected_revision: expected_revision.clone(),
27203            text: "new\n".to_owned(),
27204        };
27205        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27206            .await
27207            .unwrap_err();
27208        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27209        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27210            .await
27211            .unwrap_err();
27212        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27213        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27214        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27215        let NodeResponse::WorkspaceFileWritten { file } = response else {
27216            panic!("workspace file write returned another response");
27217        };
27218        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27219        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27220        let stale = process_request_inner(
27221            &shared,
27222            77,
27223            ClientRole::Operator,
27224            NodeRequest::WriteWorkspaceFile {
27225                workspace_id,
27226                path,
27227                expected_revision,
27228                text: "lost update\n".to_owned(),
27229            },
27230        )
27231        .await
27232        .unwrap_err();
27233        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27234        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27235        std::fs::remove_dir_all(root).unwrap();
27236    }
27237
27238    #[tokio::test]
27239    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27240        let root = temporary_workspace_root("workspace-entry-create-controller");
27241        std::fs::create_dir_all(root.join("src")).unwrap();
27242        let workspace_id = WorkspaceId::new("primary").unwrap();
27243        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27244        let catalog = active_registry().unwrap();
27245        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27246        let shared = NodeShared::new(
27247            handle,
27248            "fixture-token".to_owned(),
27249            NodeId::new("node-1").unwrap(),
27250            vec![workspace],
27251            vec![agent("claude")],
27252        );
27253        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27254        let request = NodeRequest::CreateWorkspaceFile {
27255            workspace_id: workspace_id.clone(),
27256            path: path.clone(),
27257        };
27258        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27259            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27260        }));
27261        assert!(!request_uses_unnegotiated_capability(
27262            &request,
27263            &baseline_capabilities().unwrap(),
27264        ));
27265        assert!(request_uses_unnegotiated_capability(&request, &[]));
27266        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27267            .await
27268            .unwrap_err();
27269        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27270        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27271            .await
27272            .unwrap_err();
27273        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27274        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27275
27276        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27277            .await
27278            .unwrap();
27279        let NodeResponse::WorkspaceFileCreated { file } = response else {
27280            panic!("workspace file create returned another response");
27281        };
27282        assert_eq!(file.workspace_id, workspace_id);
27283        assert_eq!(file.path, path);
27284        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27285        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27286
27287        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27288            .await
27289            .unwrap_err();
27290        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27291
27292        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27293        let response = process_request_inner(
27294            &shared,
27295            77,
27296            ClientRole::Operator,
27297            NodeRequest::CreateWorkspaceDirectory {
27298                workspace_id: workspace_id.clone(),
27299                path: directory_path.clone(),
27300            },
27301        )
27302        .await
27303        .unwrap();
27304        assert_eq!(
27305            response,
27306            NodeResponse::WorkspaceDirectoryCreated {
27307                workspace_id: workspace_id.clone(),
27308                entry: WorkspaceEntry {
27309                    relative_path: directory_path,
27310                    kind: WorkspaceEntryKind::Directory,
27311                },
27312            },
27313        );
27314        assert!(root.join("src/nested").is_dir());
27315
27316        let missing_parent = process_request_inner(
27317            &shared,
27318            77,
27319            ClientRole::Operator,
27320            NodeRequest::CreateWorkspaceFile {
27321                workspace_id: workspace_id.clone(),
27322                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27323            },
27324        )
27325        .await
27326        .unwrap_err();
27327        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27328        let non_directory_parent = process_request_inner(
27329            &shared,
27330            77,
27331            ClientRole::Operator,
27332            NodeRequest::CreateWorkspaceFile {
27333                workspace_id,
27334                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27335            },
27336        )
27337        .await
27338        .unwrap_err();
27339        assert_eq!(
27340            non_directory_parent.code,
27341            NodeFailureCode::RepositoryParentNotDirectory,
27342        );
27343        std::fs::remove_dir_all(root).unwrap();
27344    }
27345
27346    #[tokio::test]
27347    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27348        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27349        std::fs::create_dir_all(root.join("src")).unwrap();
27350        #[cfg(windows)]
27351        let root = std::fs::canonicalize(root).unwrap();
27352        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27353        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27354        let barrier = Arc::new(std::sync::Barrier::new(2));
27355        let done = Arc::new(AtomicBool::new(false));
27356        let task_commit_state = Arc::clone(&commit_state);
27357        let task_barrier = Arc::clone(&barrier);
27358        let task_done = Arc::clone(&done);
27359        let task_root = root.clone();
27360        let task = tokio::task::spawn_blocking(move || {
27361            task_barrier.wait();
27362            let result = create_workspace_file_on_disk(
27363                &task_root,
27364                &path,
27365                &task_commit_state,
27366            );
27367            task_done.store(true, Ordering::Release);
27368            result
27369        });
27370
27371        let error = settle_workspace_entry_create(
27372            task,
27373            Arc::clone(&commit_state),
27374            Duration::from_millis(10),
27375            "fixture deadline",
27376            "fixture task failed",
27377        )
27378        .await
27379        .unwrap_err();
27380        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27381        assert_eq!(
27382            commit_state.load(Ordering::Acquire),
27383            WORKSPACE_ENTRY_CREATE_CANCELED,
27384        );
27385
27386        barrier.wait();
27387        timeout(Duration::from_secs(1), async {
27388            while !done.load(Ordering::Acquire) {
27389                tokio::task::yield_now().await;
27390            }
27391        })
27392        .await
27393        .expect("canceled blocking create did not settle");
27394        assert!(!root.join("src/late.rs").exists());
27395        std::fs::remove_dir_all(root).unwrap();
27396    }
27397
27398}