Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod node_wake;
213
214mod browser_bridge;
215
216use gate4agent_node_core::drive_loop_profile;
217
218#[path = "bundle_delivery.rs"]
219mod bundle_delivery;
220
221#[path = "context_pack_store.rs"]
222mod context_pack_store;
223
224#[cfg(windows)]
225pub use crate::platform::DEFAULT_NODE_ENDPOINT;
226
227const NODE_EVENT_HISTORY_MAX: usize = 4_096;
228const NODE_BROADCAST_CAPACITY: usize = 1_024;
229const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
230/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
231/// terminal frame is a full-screen snapshot; only the latest ever matters),
232/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
233/// two pending sends into one would silently drop content a subscriber can
234/// never recover. This needs headroom for a burst instead.
235const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
236/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
237/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
238/// admission check), and `publish_transient` sends each exactly once as a
239/// single-envelope batch -- so this many slots covers the entire pending
240/// set without ever lagging the one subscriber that matters, the harness's
241/// own connection, the way sharing the durable `event_tx`'s bus could.
242const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
243const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
244const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
245const MAX_PREAUTH_CONNECTIONS: usize = 32;
246const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
247const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
248const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
249const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
250const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
251/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
252/// record retention policy (`NodeServerConfig::session_record_retention`).
253/// A sweep cadence, not a policy value -- the policy itself lives entirely
254/// in the operator-configured age/keep-N flags; this just bounds how stale
255/// the in-memory view of dead `Unavailable` records can get between sweeps
256/// on an otherwise-idle node, mirroring the once-per-startup sweep that
257/// also runs alongside `reconcile_managed_worktrees`.
258const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
259/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
260/// field's own doc for why a spawn waiter needs this at all. Must outlive
261/// the longest a waiter can still be polling for this exact command's
262/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
263/// largest `deadline_ms` any caller may request, and the margin on top
264/// absorbs the waiter's own poll cadence plus clock skew between the drive
265/// loop (writer) and the waiter (reader).
266const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
267/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
268/// backlog of asynchronously-rejected commands nobody ever collects (a
269/// waiter that stops polling, or a burst of always-rejected commands from a
270/// misbehaving caller): once at capacity, the oldest entry is evicted to
271/// make room for the newest, mirroring the eviction rule already used by
272/// `spawn_idempotency`.
273const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
274/// How long `wait_until_removed` waits between two `Remove` re-dispatches
275/// for the same session. The presence poll runs far faster (2ms); this
276/// only paces the sending, so an accepted `Remove` still in flight is not
277/// piled on by a dozen more that land on an already-dropped instance.
278const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
279const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
280const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
281const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
282const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
283const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
284const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
285const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
286const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
287const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
288const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
289const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
290const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
291/// Default total wall-clock budget for `inspect_workspace`'s walk+git
292/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
293const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
294/// The harness relay enforces its own flat response deadline
295/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
296/// 12s) above this node-side budget. The two constants live in different
297/// crates with no shared definition, so clamp any override here comfortably
298/// below that external ceiling — a misconfigured value above it would
299/// reopen the exact deadline race this budget exists to close.
300const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
301/// Default cap on directory entries visited (not just returned) by one
302/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
303const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
304const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
305const GIT_STATUS_MAX_ENTRIES: usize = 128;
306const GIT_COMMIT_MAX_ENTRIES: usize = 12;
307const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
308const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
309const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
310const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
311const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
312const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
313const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
314const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
315const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
316const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
317    "provider-session-identity-allocation-failed";
318const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
319const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
320const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
321const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
322const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
323const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
324const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
325    "durable-state-path-semantics-unsupported";
326
327fn opaque_windows_path(value: String) -> OpaqueHostPath {
328    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
329}
330
331fn agent_progress_from_provider_snapshot(
332    address: SessionAddress,
333    provider: &ProviderSnapshot,
334) -> Option<SessionAgentProgress> {
335    let mut truncated = false;
336    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
337    let mut active_tool_labels = Vec::with_capacity(
338        provider
339            .active_tools
340            .len()
341            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
342    );
343    for tool in &provider.active_tools {
344        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
345            truncated = true;
346            break;
347        }
348        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
349            active_tool_labels.push(label);
350        }
351    }
352    let attention = provider
353        .interactions
354        .iter()
355        .find(|interaction| {
356            matches!(
357                interaction.status,
358                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
359            )
360        })
361        .map(|interaction| AgentProgressAttentionV1 {
362            kind: match interaction.interaction_kind {
363                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
364                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
365            },
366            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
367        });
368    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
369    let usage = (provider.completed_turns > 0
370        || provider.usage.input_tokens > 0
371        || provider.usage.output_tokens > 0
372        || provider.usage.cache_read_tokens > 0
373        || provider.usage.cache_write_tokens > 0
374        || provider.usage.reasoning_tokens > 0)
375        .then_some(AgentProgressUsageV1 {
376            input_tokens: provider.usage.input_tokens,
377            output_tokens: provider.usage.output_tokens,
378            cache_read_tokens: provider.usage.cache_read_tokens,
379            cache_write_tokens: provider.usage.cache_write_tokens,
380            reasoning_tokens: provider.usage.reasoning_tokens,
381        });
382    let progress = AgentProgressV1 {
383        provider_sequence: provider.sequence,
384        activity: provider.activity,
385        completed_turns: provider.completed_turns,
386        usage,
387        current: AgentProgressCurrentV1::from(provider.activity),
388        active_tool_labels,
389        active_tool_count,
390        attention,
391        subagent_count,
392        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
393        gap_count: provider.gap_count,
394        stale: provider.stale,
395        truncated,
396    };
397    let entry = SessionAgentProgress { address, progress };
398    serde_json::to_vec(&entry)
399        .ok()
400        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
401        .map(|_| entry)
402}
403
404fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
405    match u32::try_from(count) {
406        Ok(count) => count,
407        Err(_) => {
408            *truncated = true;
409            u32::MAX
410        }
411    }
412}
413
414fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
415    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
416        return None;
417    }
418    Some(match event {
419        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
420        ProviderEvent::SessionIdentityObserved { .. } => {
421            AgentProgressEventKindV1::SessionIdentityObserved
422        }
423        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
424        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
425        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
426        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
427        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
428        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
429        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
430        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
431        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
432        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
433        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
434        ProviderEvent::InteractionRequested { .. } => {
435            AgentProgressEventKindV1::InteractionRequested
436        }
437        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
438        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
439        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
440        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
441        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
442        ProviderEvent::UnrecognizedNotification { .. } => {
443            AgentProgressEventKindV1::UnrecognizedNotification
444        }
445        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
446        // ACP session/update coverage beyond text/tool/turn streaming
447        // (`plan`, `available_commands_update`, `current_mode_update`,
448        // `session_info_update`, `usage_update`, `config_option_update`,
449        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
450        // wire enum without a `#[serde(other)]` fallback; minting new
451        // variants for it is a deliberate wire-contract decision for
452        // whoever owns this protocol, not a side effect of parsing more of
453        // ACP's own wire -- until that decision is made, these fold into
454        // the same "we saw something, no dedicated kind for it yet" bucket
455        // as `UnrecognizedNotification`.
456        ProviderEvent::UserMessage { .. }
457        | ProviderEvent::Plan { .. }
458        | ProviderEvent::AvailableCommandsUpdated { .. }
459        | ProviderEvent::ModeChanged { .. }
460        | ProviderEvent::SessionInfoUpdated { .. }
461        | ProviderEvent::UsageUpdated { .. }
462        | ProviderEvent::ConfigOptionsUpdated { .. } => {
463            AgentProgressEventKindV1::UnrecognizedNotification
464        }
465    })
466}
467
468/// Maps this harness's own `HostDecisionAuthority` onto the wider
469/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
470/// chunk carries -- the four authorities a `Denied` decision is ever made
471/// under, one-for-one. The remaining `BlockAuthorityV1` variants
472/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
473/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
474/// block the PROVIDER side of the wire reported -- this function never
475/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
476/// them; the provider-side blocks below mint those directly from typed fields.
477/// Whether a provider source is a live session transport -- the only kind
478/// whose events reach the agent stream. History, resume, session-option and
479/// capability-probe sources are not sessions and stream nothing.
480fn provider_family_is_live(family: AdapterFamily) -> bool {
481    match family {
482        AdapterFamily::PtySemantic
483        | AdapterFamily::Hook
484        | AdapterFamily::ManagedHook
485        | AdapterFamily::Pipe
486        | AdapterFamily::OneShot
487        | AdapterFamily::Acp => true,
488        AdapterFamily::History
489        | AdapterFamily::Resume
490        | AdapterFamily::SessionOptions
491        | AdapterFamily::CapabilityProbe => false,
492    }
493}
494
495fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
496    match by {
497        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
498        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
499        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
500        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
501    }
502}
503
504/// The `Blocked::reason` for a `Denied` decision this build's own
505/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
506/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
507/// request_permission` request resolved by `AcpSession::
508/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
509/// neither of which computes free text (an operator answers approve/reject,
510/// never a comment; an unattended deadline asks nobody anything). `reason`
511/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
512/// -- a categorical fallback naming the authority is the
513/// honest minimum, never a placeholder standing in for detail nobody
514/// reported.
515fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
516    match authority {
517        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
518        BlockAuthorityV1::HostPolicy => "denied by host policy",
519        BlockAuthorityV1::HostDeadline => {
520            "denied by host policy after the operator deadline expired"
521        }
522        BlockAuthorityV1::Operator => "declined by the operator",
523        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
524        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
525        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
526        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
527        BlockAuthorityV1::UserRejected => "rejected by the user",
528        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
529        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
530    }
531}
532
533/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
534/// for a `HostRequestObserved` `Denied` decision.
535fn host_request_denied_block(
536    method: &str,
537    by: ProviderHostDecisionAuthority,
538    reason: Option<&str>,
539) -> (String, BlockAuthorityV1, String) {
540    let authority = block_authority(by);
541    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
542    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
543    // rather than truncating -- this cut happens here, at the producer, before
544    // the validator ever sees the text.
545    let (reason_text, _) =
546        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
547    (tool_class_label(method), authority, reason_text)
548}
549
550// ---------------------------------------------------------------------------
551// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
552// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
553// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
554// prompt` RPC error whose vendor code names a quota/limit/rate
555// exhaustion). See
556// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
557// §3 slice K2.
558// ---------------------------------------------------------------------------
559
560/// Classifies ACP's `nonExecutionKind` into the typed authority a block
561/// this build mints under, or `None` when the tool truly did not run for a
562/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
563/// TURN stopped (session closed, the operator cancelled), not that a
564/// policy/classifier/rule refused the call, so those (and any unrecognized
565/// future value) stay a plain `ToolCompleted { success: false }` only, per
566/// this plan's own rule that a tool failure is never inflated into a block
567/// this build cannot actually attribute.
568fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
569    match kind? {
570        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
571        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
572        _ => None,
573    }
574}
575
576/// Splits a provider's own denial sentence into `reason`/`help`, when it
577/// matches the fixed shape Claude Code's classifier/permission-rule denial
578/// uses -- verified live, the owner's sample fixture
579/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
580/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
581/// `authority` is NEVER derived from this text (see this module's own rule
582/// that authority comes only from a typed field) -- only `reason`/`help`
583/// are. Falls back to the full text as `reason` with no `help` when the
584/// shape does not match (a provider whose text does not carry this exact
585/// wording) -- the honest minimum: the tool truly did not run, so SOME
586/// reason is always reported, never fabricated detail beyond what arrived.
587fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
588    let trimmed = text.trim();
589    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
590        return (trimmed.to_owned(), None);
591    };
592    let tail = after_reason.trim_start();
593    let Some((reason, help)) = tail.split_once(". ") else {
594        // "Reason:" present but no ". " to split help off -- the whole
595        // tail is the reason, e.g. "Reason: Blocked by classifier." with
596        // no help text following on some providers/versions.
597        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
598        return (reason.to_owned(), None);
599    };
600    let help = help.trim();
601    if help.is_empty() {
602        (reason.trim().to_owned(), None)
603    } else {
604        (reason.trim().to_owned(), Some(help.to_owned()))
605    }
606}
607
608/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
609/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
610/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
611/// name is never threaded through to its completion). Reads the class off
612/// the guidance tail's own "add a <Class> permission rule" sentence when
613/// present (the shape the owner's sample fixture uses) -- text matching
614/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
615/// this module's own rule). Falls back to the same generic `"Tool"` class
616/// `ToolCompleted`'s own observation already uses when nothing names one.
617fn tool_class_from_denial_help(help: Option<&str>) -> String {
618    help.and_then(|text| text.split("add a ").nth(1))
619        .and_then(|tail| tail.split(' ').next())
620        .filter(|word| !word.is_empty())
621        .map(str::to_owned)
622        .unwrap_or_else(|| "Tool".to_owned())
623}
624
625/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
626/// message) names a quota/rate-limit exhaustion -- Codex's
627/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
628/// contains "limit", the word this build actually keys on; "quota"/"rate"
629/// are kept for whichever OTHER vendor's own code names one of those
630/// instead, never confirmed live, but the honest generalization of the
631/// one confirmed sample.
632fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
633    fn names_quota(s: &str) -> bool {
634        let lower = s.to_ascii_lowercase();
635        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
636    }
637    vendor_code.is_some_and(names_quota) || names_quota(message)
638}
639
640/// The `Blocked` chunk this build mints when a turn stopped via a
641/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
642/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
643/// vendor code (or message) names a quota/limit/rate exhaustion
644/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
645/// `reason_kind` = the vendor code). `None` for every other stop --
646/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
647/// `Other`, or an RPC error whose vendor code/message names none of the
648/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
649/// those, never inflated into a block this build cannot actually
650/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
651/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
652/// honest minimum names the fact that happened, never invented detail; a
653/// later slice that threads the turn's last agent text chunk through can
654/// replace it without changing this function's contract.
655fn provider_session_end_block(
656    stop_reason: &ProviderStopReason,
657) -> Option<(BlockAuthorityV1, String, String)> {
658    match stop_reason {
659        ProviderStopReason::Refusal => Some((
660            BlockAuthorityV1::ProviderRefusal,
661            "refusal".to_owned(),
662            "the provider ended the turn with stopReason: refusal".to_owned(),
663        )),
664        ProviderStopReason::ProviderError { message, vendor_code, .. }
665            if provider_error_is_quota(vendor_code.as_deref(), message) =>
666        {
667            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
668            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
669        }
670        _ => None,
671    }
672}
673
674/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
675/// `agent-stream-events-v1` channel -- see
676/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
677/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
678/// event it derives from, so a client joining the two sees one id and one
679/// ordering:
680///
681/// - `source_sequence` on the chunk is the `source_sequence` the provider
682///   event itself carries.
683/// - `InteractionPrompt::correlation_id` is minted by
684///   `correlation::interaction_correlation` with the
685///   `(instance_id, generation, provider_sequence)` triple -- never the
686///   provider's own `request_id`, whose opacity on the wire is pinned by a
687///   dedicated test.
688///
689/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
690/// (nothing else carries stream content), for a source that is not a live
691/// session transport, and for the `ProviderEvent` variants below with no
692/// declared chunk kind to carry them, or whose declared chunk kind this build
693/// has no source for yet:
694///
695/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
696///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
697///   yet, the same gap the plan names explicitly.
698/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
699///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
700///   as "what the agent is saying", so mapping the operator's words onto
701///   `Text` would misrepresent whose content it is. No dedicated
702///   user-message kind is declared, so this stays unproduced rather than
703///   mislabeled.
704///
705/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
706/// decision returns `None` (nothing for the operator to react to), a `Denied`
707/// decision returns `Some(Blocked { .. })`.
708///
709/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
710/// below (K2): each returns `None` unless
711/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
712/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
713/// RPC error naming a quota).
714fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
715    let ControlEventKind::ProviderEvent {
716        sequence: provider_sequence,
717        source,
718        source_sequence,
719        event: provider_event,
720    } = &event.event
721    else {
722        return None;
723    };
724    if !provider_family_is_live(source.family) {
725        return None;
726    }
727    let kind = match provider_event {
728        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
729            text: text.clone(),
730            is_delta: *is_delta,
731        },
732        ProviderEvent::Thinking { text } => {
733            AgentStreamChunkKindV1::Thinking { text: text.clone() }
734        }
735        ProviderEvent::InteractionRequested {
736            interaction_kind,
737            tool_name,
738            title,
739            prompt,
740            options,
741            ..
742        } => AgentStreamChunkKindV1::InteractionPrompt {
743            correlation_id: interaction_correlation(
744                event.instance_id,
745                event.generation,
746                *provider_sequence,
747            ),
748            interaction_kind: *interaction_kind,
749            tool_name: tool_name.clone(),
750            // `title`/`options` are carried straight through from
751            // `ProviderEvent::InteractionRequested`, as the agent itself
752            // gave them on `session/request_permission`'s `PermissionTool
753            // Call.title`/`options` (see `gate4agent-shell-native`'s
754            // `provider_event`, the only ACP-transport producer). A PTY- or
755            // hook-sourced interaction carries `title: None` and
756            // `options: []` from its own producer because that source has
757            // none to offer, never because this mapping dropped one.
758            title: title.clone(),
759            prompt: prompt.clone(),
760            options: options
761                .iter()
762                .map(|option| AgentStreamInteractionOptionV1 {
763                    option_id: option.option_id.clone(),
764                    name: option.name.clone(),
765                    kind: option.kind.clone(),
766                })
767                .collect(),
768        },
769        ProviderEvent::ConfigOptionsUpdated { options } => {
770            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
771        }
772        // `available` is the mode catalogue `AcpSession::available_modes()`
773        // read back at `session/new` and threaded through by
774        // `gate4agent-shell-native`'s `provider_event` (see that crate's
775        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
776        // agent's own honest "no modes announced", never "not read": ACP
777        // orders `session/new` before any `session/update`, and the
778        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
779        // exist before the same session object's catalogue is already
780        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
781        // own doc comment for the ordering argument in full).
782        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
783            current: Some(mode_id.clone()),
784            available: available
785                .iter()
786                .map(|mode| AgentStreamNamedIdV1 {
787                    id: mode.id.clone(),
788                    name: mode.name.clone(),
789                    description: mode.description.clone(),
790                })
791                .collect(),
792        },
793        // A denied host request is a block -- see this function's own doc
794        // comment for why `HostRequestObserved` is not in the exclusion list
795        // below.
796        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
797            let ProviderHostRequestDecision::Denied { by } = decision else {
798                return None;
799            };
800            let (tool_class, authority, reason_text) =
801                host_request_denied_block(method, *by, reason.as_deref());
802            AgentStreamChunkKindV1::Blocked {
803                correlation_id: None,
804                tool_class,
805                authority,
806                reason_kind: None,
807                reason: reason_text,
808                help: None,
809            }
810        }
811        // Same relationship as `HostRequestObserved` above, driven by ACP's
812        // own `_meta.nonExecutionKind` instead of the host's
813        // `HostRequestDecision`.
814        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
815            let (authority, reason_kind) =
816                provider_tool_block_authority(non_execution_kind.as_deref())?;
817            let (reason, help) = split_provider_denial_text(output);
818            let (reason, _) =
819                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
820            let help = help.map(|help| {
821                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
822            });
823            AgentStreamChunkKindV1::Blocked {
824                correlation_id: Some(tool_correlation(event, source, id)),
825                tool_class: tool_class_from_denial_help(help.as_deref()),
826                authority,
827                reason_kind: Some(reason_kind.to_owned()),
828                reason,
829                help,
830            }
831        }
832        // Same relationship, driven by `stopReason: "refusal"` or a
833        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
834        // instead of a tool-level fact.
835        ProviderEvent::SessionEnded { stop_reason, .. } => {
836            let (authority, reason_kind, reason) =
837                stop_reason.as_ref().and_then(provider_session_end_block)?;
838            let (reason, _) =
839                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
840            AgentStreamChunkKindV1::Blocked {
841                correlation_id: None,
842                tool_class: "Session".to_owned(),
843                authority,
844                reason_kind: Some(reason_kind),
845                reason,
846                help: None,
847            }
848        }
849        ProviderEvent::SessionStarted { .. }
850        | ProviderEvent::SessionIdentityObserved { .. }
851        | ProviderEvent::TurnStarted { .. }
852        | ProviderEvent::WorkingObserved
853        | ProviderEvent::ToolStarted { .. }
854        | ProviderEvent::TurnCompleted { .. }
855        | ProviderEvent::ContextWindowUsage { .. }
856        | ProviderEvent::TurnInterrupted
857        | ProviderEvent::Error { .. }
858        | ProviderEvent::Ready
859        | ProviderEvent::InteractionResolved { .. }
860        | ProviderEvent::SubagentStarted { .. }
861        | ProviderEvent::SubagentStopped { .. }
862        | ProviderEvent::RateLimited { .. }
863        | ProviderEvent::UnrecognizedNotification { .. }
864        | ProviderEvent::UserMessage { .. }
865        | ProviderEvent::Plan { .. }
866        | ProviderEvent::AvailableCommandsUpdated { .. }
867        | ProviderEvent::SessionInfoUpdated { .. }
868        | ProviderEvent::UsageUpdated { .. } => return None,
869    };
870    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
871}
872
873async fn run_native_session_catalog_operation<T, E, F>(
874    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
875    operation: &'static str,
876    execute: F,
877) -> Result<T, NodeFailure>
878where
879    T: Send + 'static,
880    E: std::fmt::Display + Send + 'static,
881    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
882{
883    let task = tokio::task::spawn_blocking(move || {
884        let mut catalog = match catalog.try_lock() {
885            Ok(catalog) => catalog,
886            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
887            Err(std::sync::TryLockError::WouldBlock) => {
888                return Err(failure(
889                    NodeFailureCode::BackendBusy,
890                    "native session catalog operation is already running",
891                ));
892            }
893        };
894        execute(&mut catalog).map_err(|error| {
895            failure(
896                NodeFailureCode::BackendOperationFailed,
897                &format!("{operation} failed: {error}"),
898            )
899        })
900    });
901    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
902        .await
903        .map_err(|_| {
904            failure(
905                NodeFailureCode::BackendBusy,
906                &format!("{operation} exceeded its bounded deadline"),
907            )
908        })?
909        .map_err(|_| {
910            failure(
911                NodeFailureCode::BackendOperationFailed,
912                &format!("{operation} task failed"),
913            )
914        })?
915}
916
917fn windows_path_text(path: &OpaqueHostPath) -> &str {
918    path.as_utf8()
919        .expect("Windows node only creates UTF-8 opaque host paths")
920}
921
922fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
923    path.as_utf8()
924        .map(str::to_owned)
925        .ok_or_else(|| failure(
926            NodeFailureCode::InvalidRequest,
927            "this node requires UTF-8 host paths",
928        ))
929}
930
931fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
932    GitWorktreeSnapshot {
933        path: opaque_windows_path(worktree.path),
934        head: worktree.head,
935        branch: worktree.branch,
936        is_bare: worktree.is_bare,
937        is_main: worktree.is_main,
938        locked: worktree.locked,
939        lock_reason: worktree.lock_reason,
940        prunable: worktree.prunable,
941        prunable_reason: worktree.prunable_reason,
942        workspace_id: worktree.workspace_id.take(),
943    }
944}
945
946#[derive(Clone, Debug, Eq, PartialEq)]
947pub struct WorkspaceConfig {
948    workspace_id: WorkspaceId,
949    canonical_root: String,
950    worktree_service_mode: WorktreeServiceMode,
951    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
952}
953
954impl WorkspaceConfig {
955    pub fn new(
956        workspace_id: WorkspaceId,
957        root: impl AsRef<Path>,
958    ) -> Result<Self, NodeServerError> {
959        let root = root.as_ref();
960        if !root.is_absolute() {
961            return Err(NodeServerError::InvalidWorkspaceRoot {
962                workspace_id,
963                path: root.to_string_lossy().into_owned(),
964                message: "path is not absolute".to_owned(),
965            });
966        }
967        let canonical = std::fs::canonicalize(root).map_err(|error| {
968            NodeServerError::InvalidWorkspaceRoot {
969                workspace_id: workspace_id.clone(),
970                path: root.to_string_lossy().into_owned(),
971                message: error.to_string(),
972            }
973        })?;
974        if !canonical.is_dir() {
975            return Err(NodeServerError::InvalidWorkspaceRoot {
976                workspace_id,
977                path: root.to_string_lossy().into_owned(),
978                message: "path is not a directory".to_owned(),
979            });
980        }
981        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
982            NodeServerError::InvalidWorkspaceRoot {
983                workspace_id: workspace_id.clone(),
984                path: path.to_string_lossy().into_owned(),
985                message: "canonical path is not valid Unicode".to_owned(),
986            }
987        })?;
988        let canonical_root = platform::normalize_canonical_root(canonical_root);
989        validate_workspace_root(&workspace_id, &canonical_root)?;
990        Ok(Self {
991            workspace_id,
992            canonical_root,
993            worktree_service_mode: WorktreeServiceMode::Manual,
994            managed_worktree_profiles: BTreeMap::new(),
995        })
996    }
997
998    pub fn workspace_id(&self) -> &WorkspaceId {
999        &self.workspace_id
1000    }
1001
1002    pub fn canonical_root(&self) -> &str {
1003        &self.canonical_root
1004    }
1005
1006    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1007        self.worktree_service_mode = mode;
1008        self
1009    }
1010
1011    pub fn with_managed_worktree_profile(
1012        mut self,
1013        profile: ManagedWorktreeProfile,
1014    ) -> Result<Self, NodeServerError> {
1015        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1016            NodeServerError::InvalidManagedWorktreeProfile {
1017                workspace_id: self.workspace_id.clone(),
1018                profile_id: profile.profile_id().clone(),
1019                message,
1020            }
1021        })?;
1022        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1023            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1024                workspace_id: self.workspace_id,
1025                profile_id: profile.profile_id().clone(),
1026            });
1027        }
1028        if self.managed_worktree_profiles.len()
1029            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1030        {
1031            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1032                workspace_id: self.workspace_id,
1033                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1034            });
1035        }
1036        self.managed_worktree_profiles
1037            .insert(profile.profile_id().clone(), profile);
1038        Ok(self)
1039    }
1040}
1041
1042#[derive(Clone)]
1043pub struct NodeServerConfig {
1044    pub endpoint: String,
1045    api_listen: Option<std::net::SocketAddr>,
1046    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1047    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1048    bridge_listen: Option<std::net::SocketAddr>,
1049    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1050    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1051    bridge_token: Option<String>,
1052    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1053    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1054    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1055    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1056    /// node purely a listener, which is what it has always been.
1057    call_home: Option<std::net::SocketAddr>,
1058    /// When set, call-home authenticates by signing the relay's challenge with this
1059    /// identity (enrolled node) and falls back to the token handshake against a relay
1060    /// that sends no challenge.
1061    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1062    pub node_id: NodeId,
1063    pub workspaces: Vec<WorkspaceConfig>,
1064    access_token: String,
1065    pub runtime: NativeRuntimeConfig,
1066    session_record_retention: SessionRecordRetentionConfig,
1067    state_path: Option<PathBuf>,
1068    spawn_profiles: SpawnProfileRegistry,
1069    session_environment: Option<NodeSessionEnvironmentConfig>,
1070    history: Option<NativeHistoryConfig>,
1071    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1072    /// Station network allowlist catalog (opaque ids). Empty default.
1073    /// Loaded at startup from `--network-allowlist-catalog` / env
1074    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1075    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1076    #[cfg(feature = "fixture")]
1077    fixture_raw_pty_runtime: bool,
1078}
1079
1080#[derive(Clone)]
1081struct NodeSessionEnvironmentConfig {
1082    root: PathBuf,
1083    resolver: Arc<dyn NodeSecretResolver>,
1084}
1085
1086impl NodeServerConfig {
1087    pub fn new(
1088        endpoint: impl Into<String>,
1089        access_token: impl Into<String>,
1090        node_id: NodeId,
1091        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1092    ) -> Result<Self, NodeServerError> {
1093        let endpoint = endpoint.into();
1094        let access_token = access_token.into();
1095        if !platform::validate_endpoint(&endpoint) {
1096            return Err(NodeServerError::InvalidEndpoint);
1097        }
1098        if access_token.is_empty() || access_token.len() > 4_096 {
1099            return Err(NodeServerError::InvalidAccessToken);
1100        }
1101        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1102        if workspaces.is_empty() {
1103            return Err(NodeServerError::NoWorkspaces);
1104        }
1105        let mut workspace_ids = BTreeMap::new();
1106        let mut workspace_roots = BTreeMap::new();
1107        for workspace in &workspaces {
1108            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1109                && workspace.managed_worktree_profiles.is_empty()
1110            {
1111                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1112                    workspace.workspace_id.clone(),
1113                ));
1114            }
1115            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1116                && !workspace.managed_worktree_profiles.is_empty()
1117            {
1118                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1119                    workspace.workspace_id.clone(),
1120                ));
1121            }
1122            if workspace_ids
1123                .insert(workspace.workspace_id.clone(), ())
1124                .is_some()
1125            {
1126                return Err(NodeServerError::DuplicateWorkspaceId(
1127                    workspace.workspace_id.clone(),
1128                ));
1129            }
1130            let root_key = platform::root_identity(&workspace.canonical_root);
1131            if let Some(existing) = workspace_roots.insert(
1132                root_key,
1133                workspace.workspace_id.clone(),
1134            ) {
1135                return Err(NodeServerError::DuplicateWorkspaceRoot {
1136                    first: existing,
1137                    second: workspace.workspace_id.clone(),
1138                    root: workspace.canonical_root.clone(),
1139                });
1140            }
1141        }
1142        Ok(Self {
1143            endpoint,
1144            api_listen: None,
1145            bridge_listen: None,
1146            bridge_token: None,
1147            bridge_underlay: None,
1148            call_home: None,
1149            call_home_identity: None,
1150            node_id,
1151            workspaces,
1152            access_token,
1153            // PTY output is polled by the runtime's effect worker; the upstream
1154            // default (20ms) added ~10-20ms to every keystroke echo.
1155            runtime: NativeRuntimeConfig {
1156                worker_poll_interval_ms: 4,
1157                ..NativeRuntimeConfig::default()
1158            },
1159            session_record_retention: SessionRecordRetentionConfig::default(),
1160            state_path: None,
1161            spawn_profiles: SpawnProfileRegistry::default(),
1162            session_environment: None,
1163            history: None,
1164            harness_mcp_helper: None,
1165            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1166            #[cfg(feature = "fixture")]
1167            fixture_raw_pty_runtime: false,
1168        })
1169    }
1170
1171    pub fn with_api_listen(
1172        mut self,
1173        api_listen: std::net::SocketAddr,
1174    ) -> Result<Self, NodeServerError> {
1175        if !api_listen.ip().is_loopback() {
1176            return Err(NodeServerError::InvalidApiListen(api_listen));
1177        }
1178        self.api_listen = Some(api_listen);
1179        Ok(self)
1180    }
1181
1182    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1183    /// as `with_api_listen`). Does not enable by default — libraries and the
1184    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1185    pub fn with_bridge_listen(
1186        mut self,
1187        bridge_listen: std::net::SocketAddr,
1188    ) -> Result<Self, NodeServerError> {
1189        if !bridge_listen.ip().is_loopback() {
1190            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1191        }
1192        self.bridge_listen = Some(bridge_listen);
1193        Ok(self)
1194    }
1195
1196    /// Optional bridge shared secret, distinct from the C2/node access token.
1197    /// Never log the value. Empty / oversize refused.
1198    pub fn with_bridge_token(
1199        mut self,
1200        bridge_token: impl Into<String>,
1201    ) -> Result<Self, NodeServerError> {
1202        let bridge_token = bridge_token.into();
1203        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1204            return Err(NodeServerError::InvalidBridgeToken);
1205        }
1206        self.bridge_token = Some(bridge_token);
1207        Ok(self)
1208    }
1209
1210    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1211    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1212    /// already configured. Transport key is 32 raw bytes (never logged).
1213    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1214    pub fn with_bridge_underlay(
1215        mut self,
1216        bind: std::net::SocketAddr,
1217        transport_key: [u8; 32],
1218        underlay_token: impl Into<String>,
1219    ) -> Result<Self, NodeServerError> {
1220        if self.bridge_listen.is_none() {
1221            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1222        }
1223        if self.bridge_token.is_none() {
1224            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1225        }
1226        let underlay_token = underlay_token.into();
1227        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1228            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1229        }
1230        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1231        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1232            bind,
1233            transport_key,
1234            underlay_token,
1235        });
1236        Ok(self)
1237    }
1238
1239    /// Also dial `relay` and serve this wire over the connection, in
1240    /// addition to accepting on the local endpoint.
1241    ///
1242    /// Who opens the socket and who is the protocol's server are separate
1243    /// questions, and this is the node's half of separating them. The node
1244    /// stays the server either way -- it answers `NodeRequest`s and emits
1245    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1246    /// that changes is that a node with no address anyone can reach makes
1247    /// the connection itself, and names itself once it arrives (see
1248    /// `gate4agent_node_wire::write_call_home_announce`).
1249    ///
1250    /// Loopback only, and that is not an oversight to be flagged and
1251    /// worked around. This wire carries terminal contents, keystrokes and
1252    /// file bytes as plaintext JSON; its mutual challenge-response
1253    /// authenticates both ends and encrypts nothing. Every listener in
1254    /// this stack is loopback-guarded for that reason, and a dialler must
1255    /// hold the same line -- a node calling a public relay would put the
1256    /// whole control plane on the wire in the clear. Reaching a relay on
1257    /// another host is a transport question, and it stays unanswered here
1258    /// on purpose: this change is about direction, not distance.
1259    pub fn with_call_home(
1260        mut self,
1261        relay: std::net::SocketAddr,
1262    ) -> Result<Self, NodeServerError> {
1263        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1264        // and port 0 refused — plaintext wire must not dial every interface.
1265        if relay.port() == 0 || relay.ip().is_unspecified() {
1266            return Err(NodeServerError::InvalidCallHome(relay));
1267        }
1268        self.call_home = Some(relay);
1269        Ok(self)
1270    }
1271
1272    /// Authenticate call-home with a node-held identity instead of a standing token.
1273    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1274        self.call_home_identity = Some(identity);
1275        self
1276    }
1277
1278    pub fn with_state_path(
1279        mut self,
1280        state_path: impl Into<PathBuf>,
1281    ) -> Result<Self, NodeServerError> {
1282        let state_path = state_path.into();
1283        if !state_path.is_absolute() || state_path.file_name().is_none() {
1284            return Err(NodeServerError::InvalidStatePath(
1285                state_path.to_string_lossy().into_owned(),
1286            ));
1287        }
1288        self.state_path = Some(state_path);
1289        Ok(self)
1290    }
1291
1292    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1293        self.spawn_profiles = spawn_profiles;
1294        self
1295    }
1296
1297    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1298    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1299    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1300    /// when this is never called -- a node never deletes a durable record
1301    /// until an operator opts in with a real value.
1302    pub fn with_session_record_retention(
1303        mut self,
1304        retention: SessionRecordRetentionConfig,
1305    ) -> Self {
1306        self.session_record_retention = retention;
1307        self
1308    }
1309
1310    pub fn with_session_environment_materialization(
1311        mut self,
1312        root: impl Into<PathBuf>,
1313        resolver: Arc<dyn NodeSecretResolver>,
1314    ) -> Result<Self, NodeServerError> {
1315        let root = root.into();
1316        if !root.is_absolute() {
1317            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1318        }
1319        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1320        Ok(self)
1321    }
1322
1323    /// Enables bounded provider-history discovery and loading from explicit
1324    /// host-owned roots. Ambient vendor homes are never inferred here.
1325    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1326        self.history = Some(history);
1327        self
1328    }
1329
1330    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1331    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1332    /// Both platforms review the helper the same way (absolute regular file +
1333    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1334    /// gate after Unix listener/peer-cred support landed.
1335    pub fn with_harness_mcp_helper(
1336        mut self,
1337        helper_program: impl Into<PathBuf>,
1338    ) -> Result<Self, NodeServerError> {
1339        self.harness_mcp_helper = Some(
1340            ReviewedHarnessMcpProgram::review(helper_program.into())
1341                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1342        );
1343        Ok(self)
1344    }
1345
1346    /// Install an in-memory station network allowlist catalog (opaque ids +
1347    /// optional node-local permits / provider-native mapping). Empty remains
1348    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1349    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1350    pub fn with_network_allowlist_catalog(
1351        mut self,
1352        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1353    ) -> Result<Self, NodeServerError> {
1354        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1355            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1356                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1357            });
1358        }
1359        self.network_allowlist_catalog = catalog;
1360        Ok(self)
1361    }
1362
1363    /// Load station network allowlist catalog from an absolute regular file
1364    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1365    /// that want env fallback use `resolve_network_allowlist_catalog`.
1366    pub fn with_network_allowlist_catalog_file(
1367        self,
1368        path: impl AsRef<std::path::Path>,
1369    ) -> Result<Self, NodeServerError> {
1370        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1371            .map_err(NodeServerError::from)?;
1372        self.with_network_allowlist_catalog(catalog)
1373    }
1374}
1375
1376pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1377    platform::default_state_path(node_id.as_str())
1378        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1379}
1380
1381pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1382    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1383}
1384
1385fn delivery_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(".delivery-store");
1389    Some(parent.join(name))
1390}
1391
1392fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1393    let parent = state_path.parent()?;
1394    let mut name = state_path.file_name()?.to_os_string();
1395    name.push(".context-pack-store");
1396    Some(parent.join(name))
1397}
1398
1399pub struct NodeServer {
1400    config: NodeServerConfig,
1401    runtime: NativeRuntime,
1402    shared: Arc<NodeShared>,
1403    events: EventSubscription,
1404    state_path_lock: Option<session_registry::StatePathLock>,
1405}
1406
1407#[cfg(feature = "fixture")]
1408#[derive(Clone, Default)]
1409pub struct SpawnManagedWorktreeV2FailureProbe {
1410    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1411}
1412
1413#[cfg(feature = "fixture")]
1414impl SpawnManagedWorktreeV2FailureProbe {
1415    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1416        *self
1417            .latest_code
1418            .lock()
1419            .unwrap_or_else(|poisoned| poisoned.into_inner())
1420    }
1421
1422    fn record(&self, code: Option<NodeFailureCode>) {
1423        *self
1424            .latest_code
1425            .lock()
1426            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1427    }
1428}
1429
1430#[cfg(all(feature = "fixture", windows))]
1431struct IsolatedCodexHomeFixtureEnvironment;
1432
1433#[cfg(all(feature = "fixture", windows))]
1434impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1435    for IsolatedCodexHomeFixtureEnvironment
1436{
1437    fn resolve_child_environment(
1438        &self,
1439    ) -> Result<
1440        Vec<gate4agent_catalog::EnvMutation>,
1441        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1442    > {
1443        Ok(vec![gate4agent_catalog::EnvMutation {
1444            key: OsString::from("USERPROFILE"),
1445            value: None,
1446        }])
1447    }
1448}
1449
1450impl NodeServer {
1451    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1452    /// must present.
1453    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1454        self.shared.m4a_accounts.as_ref()?;
1455        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1456    }
1457
1458    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1459        let catalog = active_registry()?;
1460        Self::new_with_registry(config, catalog)
1461    }
1462
1463    #[cfg(feature = "fixture")]
1464    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1465        let mut spec = gate4agent_testkit::interactive_agent_spec();
1466        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1467        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1468        Self::new_fixture_with_spec(config, spec)
1469    }
1470
1471    #[cfg(feature = "fixture")]
1472    pub fn new_clean_exit_fixture(
1473        config: NodeServerConfig,
1474        fixture_root: PathBuf,
1475        started_marker: PathBuf,
1476        release_signal: PathBuf,
1477    ) -> Result<Self, NodeServerError> {
1478        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1479            &fixture_root,
1480            &started_marker,
1481            &release_signal,
1482        )
1483        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1484        spec.id = AgentId::new("claude")
1485            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1486        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1487        Self::new_fixture_with_spec(config, spec)
1488    }
1489
1490    /// A `claude` source whose session establishes a verified provider
1491    /// identity via an early `SessionStart` Hook post (so it is eligible for
1492    /// auto-export-at-exit and its native history is independently
1493    /// loadable), waits for `release_signal`, then exits cleanly — paired
1494    /// on the same Node with a plain `codex` target that never validates
1495    /// anything about what it receives. Built for A2 durable ContextPack
1496    /// E2E coverage: the test itself inspects materialized files and
1497    /// managed-session-record fields directly, rather than having the
1498    /// target script self-validate.
1499    #[cfg(all(feature = "fixture", windows))]
1500    pub fn new_durable_context_pack_clean_exit_fixture(
1501        config: NodeServerConfig,
1502        fixture_root: PathBuf,
1503        started_marker: PathBuf,
1504        release_signal: PathBuf,
1505        session_id: &str,
1506    ) -> Result<Self, NodeServerError> {
1507        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1508            &fixture_root,
1509            &started_marker,
1510            &release_signal,
1511            session_id,
1512        )
1513        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1514        let source_id = AgentId::new("claude")
1515            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1516        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1517            NodeServerError::Registry(
1518                "durable context pack clean-exit fixture source adapter is unavailable"
1519                    .to_owned(),
1520            )
1521        })?;
1522        source_spec.capabilities.adapters.history =
1523            source_provider.capabilities.adapters.history.clone();
1524        source_spec.id = source_id;
1525        source_spec.display_name =
1526            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1527        // Both specs launch via `powershell.exe`; the registry's ambiguity
1528        // check keys on `detection.command` (+ aliases), not `launch.program`
1529        // -- give each a distinct detection identity, same precedent as
1530        // `context_pack_fixture_catalog`'s per-provider
1531        // `gate4agent-{provider}-context-fixture.cmd` commands.
1532        source_spec.detection.command =
1533            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1534        source_spec.detection.aliases.clear();
1535
1536        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1537        target_spec.id = AgentId::new("codex")
1538            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1539        target_spec.display_name =
1540            "Controlled Codex durable context-pack target fixture".to_owned();
1541        target_spec.detection.command =
1542            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1543        target_spec.detection.aliases.clear();
1544
1545        let catalog = AgentRegistry::new([source_spec, target_spec])
1546            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1547        let mut server = Self::new_with_registry(config, catalog)?;
1548        Arc::get_mut(&mut server.shared)
1549            .expect("fixture Node shared state must remain uniquely owned before run")
1550            .fixture_semantic_hook_policy = true;
1551        Ok(server)
1552    }
1553
1554    #[cfg(feature = "fixture")]
1555    pub fn new_monitoring_hook_fixture(
1556        config: NodeServerConfig,
1557    ) -> Result<Self, NodeServerError> {
1558        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1559        spec.id = AgentId::new("claude")
1560            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1561        let mut server = Self::new_fixture_with_spec(config, spec)?;
1562        Arc::get_mut(&mut server.shared)
1563            .expect("fixture Node shared state must remain uniquely owned before run")
1564            .fixture_semantic_hook_policy = true;
1565        Ok(server)
1566    }
1567
1568    #[cfg(all(feature = "fixture", windows))]
1569    pub fn new_provider_bundle_argv_fixture(
1570        config: NodeServerConfig,
1571        agent_id: AgentId,
1572        proof_path: PathBuf,
1573    ) -> Result<Self, NodeServerError> {
1574        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1575        Self::new_fixture_with_spec(config, spec)
1576    }
1577
1578    #[cfg(all(feature = "fixture", windows))]
1579    pub fn new_provider_bundle_argv_hold_fixture(
1580        config: NodeServerConfig,
1581        agent_id: AgentId,
1582        proof_path: PathBuf,
1583        release_signal: PathBuf,
1584    ) -> Result<Self, NodeServerError> {
1585        let spec = Self::provider_bundle_argv_fixture_spec(
1586            agent_id,
1587            proof_path,
1588            Some(release_signal),
1589        )?;
1590        Self::new_fixture_with_spec(config, spec)
1591    }
1592
1593    #[cfg(all(feature = "fixture", windows))]
1594    fn provider_bundle_argv_fixture_spec(
1595        agent_id: AgentId,
1596        proof_path: PathBuf,
1597        release_signal: Option<PathBuf>,
1598    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1599        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1600            return Err(NodeServerError::Registry(
1601                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1602            ));
1603        }
1604        let mut spec = gate4agent_testkit::interactive_agent_spec();
1605        spec.id = agent_id.clone();
1606        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1607        let provider = builtin_registry()
1608            .get(&agent_id)
1609            .ok_or_else(|| {
1610                NodeServerError::Registry(
1611                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1612                )
1613            })?;
1614        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1615        if !proof_path.is_absolute()
1616            || !proof_path
1617                .parent()
1618                .is_some_and(|parent| parent.is_dir())
1619        {
1620            return Err(NodeServerError::Registry(
1621                "provider bundle argv proof path requires an existing absolute parent"
1622                    .to_owned(),
1623            ));
1624        }
1625        if let Some(release_signal) = release_signal.as_ref() {
1626            if !release_signal.is_absolute()
1627                || !release_signal
1628                    .parent()
1629                    .is_some_and(|parent| parent.is_dir())
1630            {
1631                return Err(NodeServerError::Registry(
1632                    "provider bundle argv release path requires an existing absolute parent"
1633                        .to_owned(),
1634                ));
1635            }
1636            if release_signal.exists() {
1637                return Err(NodeServerError::Registry(
1638                    "provider bundle argv release path must not exist".to_owned(),
1639                ));
1640            }
1641            let proof_parent = std::fs::canonicalize(
1642                proof_path
1643                    .parent()
1644                    .expect("validated provider bundle proof parent"),
1645            )
1646            .map_err(|_| {
1647                NodeServerError::Registry(
1648                    "provider bundle argv proof parent is unavailable".to_owned(),
1649                )
1650            })?;
1651            let release_parent = std::fs::canonicalize(
1652                release_signal
1653                    .parent()
1654                    .expect("validated provider bundle release parent"),
1655            )
1656            .map_err(|_| {
1657                NodeServerError::Registry(
1658                    "provider bundle argv release parent is unavailable".to_owned(),
1659                )
1660            })?;
1661            if proof_parent != release_parent {
1662                return Err(NodeServerError::Registry(
1663                    "provider bundle argv release path must share the proof parent".to_owned(),
1664                ));
1665            }
1666        }
1667        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1668            NodeServerError::Registry(
1669                "provider bundle argv proof path is not valid Unicode".to_owned(),
1670            )
1671        })?;
1672        let release_signal = release_signal.map(|release_signal| {
1673            release_signal.into_os_string().into_string().map_err(|_| {
1674                NodeServerError::Registry(
1675                    "provider bundle argv release path is not valid Unicode".to_owned(),
1676                )
1677            })
1678        }).transpose()?;
1679        let script = spec
1680            .launch
1681            .fixed_args
1682            .pop()
1683            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1684        let bundle_validation = if agent_id.as_str() == "codex" {
1685            "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') }"
1686        } else if agent_id.as_str() == "claude" {
1687            "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 }"
1688        } else {
1689            "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 }"
1690        };
1691        if let Some(release_signal) = release_signal {
1692            spec.launch.fixed_args.push(format!(
1693                "& {{ 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} }}",
1694            ));
1695            spec.launch.fixed_args.push(proof_path);
1696            spec.launch.fixed_args.push(release_signal);
1697        } else {
1698            spec.launch.fixed_args.push(format!(
1699                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1700            ));
1701            spec.launch.fixed_args.push(proof_path);
1702        }
1703        Ok(spec)
1704    }
1705
1706    #[cfg(all(feature = "fixture", windows))]
1707    pub fn new_context_pack_fixture(
1708        config: NodeServerConfig,
1709        proof_path: PathBuf,
1710    ) -> Result<Self, NodeServerError> {
1711        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1712        Self::new_with_registry(config, catalog)
1713    }
1714
1715    #[cfg(all(feature = "fixture", windows))]
1716    pub fn new_monitoring_context_pack_fixture(
1717        config: NodeServerConfig,
1718        proof_path: PathBuf,
1719    ) -> Result<Self, NodeServerError> {
1720        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1721        let mut server = Self::new_with_registry(config, catalog)?;
1722        Arc::get_mut(&mut server.shared)
1723            .expect("fixture Node shared state must remain uniquely owned before run")
1724            .fixture_semantic_hook_policy = true;
1725        Ok(server)
1726    }
1727
1728    #[cfg(all(feature = "fixture", windows))]
1729    pub fn install_isolated_codex_home_environment_fixture(
1730        &mut self,
1731        profile_id: SpawnEnvironmentProfileId,
1732        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1733    ) -> Result<(), NodeServerError> {
1734        let provider = AgentId::new("codex")
1735            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1736        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1737            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1738                "{}-pty",
1739                profile_id.as_str(),
1740            ))
1741            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1742            provider.clone(),
1743            TransportKind::Pty,
1744            vec![OsString::from("USERPROFILE")],
1745            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1746        )
1747        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1748        let materialization = NodeSessionMaterializationProfile::new(
1749            Vec::new(),
1750            vec![crate::session_environment::NodeSessionPathBinding::new(
1751                "CODEX_HOME",
1752                crate::session_environment::NodeSessionPathClass::ProviderHome,
1753            )
1754            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1755            Vec::new(),
1756        )
1757        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1758        self.install_environment_profile(
1759            NodeEnvironmentProfile::new_with_materialization(
1760                profile_id,
1761                profile_revision,
1762                provider,
1763                [native_profile],
1764                Some(materialization),
1765            )
1766            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1767        )
1768    }
1769
1770    #[cfg(all(feature = "fixture", windows))]
1771    pub fn new_context_only_proof_fixture(
1772        config: NodeServerConfig,
1773        target_provider: AgentId,
1774        proof_path: PathBuf,
1775    ) -> Result<Self, NodeServerError> {
1776        if target_provider.as_str() != "kimi" {
1777            return Err(NodeServerError::Registry(
1778                "context-only proof fixture requires Kimi".to_owned(),
1779            ));
1780        }
1781        let catalog = Self::context_pack_fixture_catalog(
1782            proof_path,
1783            Some(&target_provider),
1784            false,
1785        )?;
1786        Self::new_with_registry(config, catalog)
1787    }
1788
1789    #[cfg(all(feature = "fixture", windows))]
1790    fn context_pack_fixture_catalog(
1791        proof_path: PathBuf,
1792        context_only_target: Option<&AgentId>,
1793        monitoring_claude_source: bool,
1794    ) -> Result<AgentRegistry, NodeServerError> {
1795        if !proof_path.is_absolute()
1796            || !proof_path
1797                .parent()
1798                .is_some_and(|parent| parent.is_dir())
1799        {
1800            return Err(NodeServerError::Registry(
1801                "context pack proof path requires an existing absolute parent".to_owned(),
1802            ));
1803        }
1804        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1805            NodeServerError::Registry(
1806                "context pack proof path is not valid Unicode".to_owned(),
1807            )
1808        })?;
1809        let providers = ["claude", "codex", "grok", "kimi"];
1810        let builtins = builtin_registry();
1811        let mut specs = Vec::with_capacity(providers.len());
1812        for provider_id in providers {
1813            let agent_id = AgentId::new(provider_id)
1814                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1815            let provider = builtins.get(&agent_id).ok_or_else(|| {
1816                NodeServerError::Registry(
1817                    "context pack fixture provider adapter is unavailable".to_owned(),
1818                )
1819            })?;
1820            let monitored_provider =
1821                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1822            let mut spec = if monitored_provider {
1823                gate4agent_testkit::monitoring_hook_agent_spec()
1824            } else {
1825                gate4agent_testkit::interactive_agent_spec()
1826            };
1827            spec.id = agent_id.clone();
1828            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1829            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1830            spec.detection.aliases.clear();
1831            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1832            if provider_id == "codex" {
1833                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1834                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1835                })?;
1836                let validation = r#"
1837$bundleArgs = @($bundleArgs)
1838if ($bundleArgs.Count -ne 0) { exit 91 }
1839$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1840$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1841$cwd = (Get-Location).ProviderPath
1842if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1843$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1844if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1845$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1846if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1847$sha = [Security.Cryptography.SHA256]::Create()
1848try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1849if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1850if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1851$contextPath = Join-Path $contextRoot 'context-pack.json'
1852if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1853$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1854if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1855$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1856$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1857$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1858$separator = [IO.Path]::DirectorySeparatorChar
1859if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1860if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1861$rawContext = [IO.File]::ReadAllText($contextPath)
1862if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1863try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1864$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1865$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1866foreach ($property in @($document.PSObject.Properties.Name)) {
1867    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1868}
1869foreach ($property in $requiredTopLevel) {
1870    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1871}
1872if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1873if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1874$messages = @($document.retained_messages)
1875if ($messages.Count -eq 0) { exit 107 }
1876foreach ($message in $messages) {
1877    $messageProperties = @($message.PSObject.Properties.Name)
1878    foreach ($property in $messageProperties) {
1879        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1880    }
1881    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1882}
1883$sha = [Security.Cryptography.SHA256]::Create()
1884try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1885[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))
1886$rawProof = [IO.File]::ReadAllText($proofPath)
1887if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1888[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1889"#;
1890                spec.launch.fixed_args.push(format!(
1891                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1892                ));
1893                spec.launch.fixed_args.push(proof_path.clone());
1894            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1895                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1896                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1897                })?;
1898                let validation = r#"
1899$bundleArgs = @($bundleArgs)
1900if ($bundleArgs.Count -ne 0) { exit 91 }
1901$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1902$cwd = (Get-Location).ProviderPath
1903if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1904$contextPath = Join-Path $contextRoot 'context-pack.json'
1905if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1906$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1907if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1908$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1910$separator = [IO.Path]::DirectorySeparatorChar
1911if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1912try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1913if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1914if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1915$messages = @($document.retained_messages)
1916if ($messages.Count -eq 0) { exit 99 }
1917$hasUser = $false
1918$hasAssistant = $false
1919foreach ($message in $messages) {
1920    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1921    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1922    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1923    else { exit 101 }
1924}
1925if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1926$sha = [Security.Cryptography.SHA256]::Create()
1927try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1928[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1929[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1930"#;
1931                spec.launch.fixed_args.push(format!(
1932                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1933                ));
1934                spec.launch.fixed_args.push(proof_path.clone());
1935            }
1936            specs.push(spec);
1937        }
1938        let catalog = AgentRegistry::new(specs)
1939            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1940        Ok(catalog)
1941    }
1942
1943    #[cfg(feature = "fixture")]
1944    pub fn new_exact_launcher_fixture(
1945        config: NodeServerConfig,
1946        agent_id: AgentId,
1947        launcher: String,
1948    ) -> Result<Self, NodeServerError> {
1949        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1950            return Err(NodeServerError::Registry(
1951                "exact launcher fixture requires a supported provider".to_owned(),
1952            ));
1953        }
1954        if launcher.contains('\0') {
1955            return Err(NodeServerError::Registry(
1956                "exact launcher fixture path contains NUL".to_owned(),
1957            ));
1958        }
1959        let launcher_path = Path::new(&launcher);
1960        if !launcher_path.is_absolute() {
1961            return Err(NodeServerError::Registry(
1962                "exact launcher fixture path must be absolute".to_owned(),
1963            ));
1964        }
1965        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1966            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1967        })?;
1968        if !metadata.is_file() {
1969            return Err(NodeServerError::Registry(
1970                "exact launcher fixture path must be a regular file".to_owned(),
1971            ));
1972        }
1973        let mut spec = builtin_registry()
1974            .get(&agent_id)
1975            .ok_or_else(|| {
1976                NodeServerError::Registry(
1977                    "exact launcher fixture provider is unavailable".to_owned(),
1978                )
1979            })?
1980            .clone();
1981        spec.launch.program = launcher;
1982        Self::new_fixture_with_spec(config, spec)
1983    }
1984
1985    /// Shared H3B validation and spec construction for
1986    /// `new_harness_mcp_proxy_fixture` and
1987    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1988    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1989    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1990    /// the named builtin catalog entry -- detection command and expected
1991    /// process matcher rebased on the launcher's own basename, exactly the
1992    /// same shape `new_harness_mcp_proxy_fixture` always built.
1993    #[cfg(feature = "fixture")]
1994    fn h3b_provider_spec_with_program_and_args(
1995        agent_id: AgentId,
1996        provider_program: PathBuf,
1997        fixed_args: Vec<OsString>,
1998    ) -> Result<AgentSpec, NodeServerError> {
1999        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2000            return Err(NodeServerError::Registry(
2001                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2002            ));
2003        }
2004        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2005            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2006        })?;
2007        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2008            return Err(NodeServerError::Registry(
2009                "H3B fixture provider must be an exact regular file".to_owned(),
2010            ));
2011        }
2012        let program = provider_program.into_os_string().into_string().map_err(|_| {
2013            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2014        })?;
2015        let fixed_args = fixed_args.into_iter().map(|argument| {
2016            argument.into_string().map_err(|_| NodeServerError::Registry(
2017                "H3B fixture argument is not Unicode".to_owned(),
2018            ))
2019        }).collect::<Result<Vec<_>, _>>()?;
2020        let argument_bytes = fixed_args.iter()
2021            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2022        if program.contains('\0')
2023            || fixed_args.iter().any(|argument| argument.contains('\0'))
2024            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2025        {
2026            return Err(NodeServerError::Registry(
2027                "H3B fixture provider or arguments are invalid".to_owned(),
2028            ));
2029        }
2030        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2031            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2032        })?.clone();
2033        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2034            .ok_or_else(|| NodeServerError::Registry(
2035                "H3B fixture provider basename is unavailable".to_owned(),
2036            ))?.to_owned();
2037        spec.detection.command = process_name.clone();
2038        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2039            name: process_name,
2040        }];
2041        spec.launch.program = program;
2042        spec.launch.fixed_args = fixed_args;
2043        Ok(spec)
2044    }
2045
2046    #[cfg(feature = "fixture")]
2047    pub fn new_harness_mcp_proxy_fixture(
2048        config: NodeServerConfig,
2049        provider_program: PathBuf,
2050        fixed_args: Vec<OsString>,
2051        harness_mcp_program: PathBuf,
2052    ) -> Result<Self, NodeServerError> {
2053        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2054        let agent_id = AgentId::new("codex")
2055            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2056        let mut spec =
2057            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2058        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2059        config.fixture_raw_pty_runtime = true;
2060        Self::new_fixture_with_spec(config, spec)
2061    }
2062
2063    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2064    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2065    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2066    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2067    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2068    /// fixture_raw_pty_runtime = true`, which tears out the live
2069    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2070    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2071    /// hardcode `acp_transport = false` for every provider, refusing
2072    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2073    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2074    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2075    /// is built from the (cloned, launcher-replaced) catalog spec, and
2076    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2077    /// unmodified catalog fact: every builtin provider this accepts
2078    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2079    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2080    /// invented here -- this constructor only removes the fixture-only
2081    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2082    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2083    /// for every ACP session, fixture or not) is what actually grants the
2084    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2085    /// this constructor never calls it directly, and does not need to.
2086    #[cfg(feature = "fixture")]
2087    pub fn new_harness_mcp_acp_launcher_fixture(
2088        config: NodeServerConfig,
2089        helper_program: PathBuf,
2090        provider: AgentId,
2091        program: PathBuf,
2092        args: Vec<OsString>,
2093    ) -> Result<Self, NodeServerError> {
2094        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2095            return Err(NodeServerError::Registry(
2096                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2097            ));
2098        }
2099        let config = config.with_harness_mcp_helper(helper_program)?;
2100        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2101        let launch = spec.launch.clone();
2102        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2103            NodeServerError::Registry(
2104                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2105                    .to_owned(),
2106            )
2107        })?;
2108        // Routes the ACP spawn itself through the exact replaced launcher
2109        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2110        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2111        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2112        // without this the ACP transport ignores `spec.launch` entirely and
2113        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2114        // codex-acp@1.10.0`), which this fixture must never reach.
2115        acp.launch_override = Some(launch);
2116        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2117        Self::new_fixture_with_spec(config, spec)
2118    }
2119
2120    #[cfg(feature = "fixture")]
2121    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2122        let mut spec = gate4agent_testkit::interactive_agent_spec();
2123        let claude_id = AgentId::new("claude")
2124            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2125        let claude = builtin_registry()
2126            .get(&claude_id)
2127            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2128        spec.id = claude_id;
2129        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2130        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2131            name: "claude".to_owned(),
2132        }];
2133        spec.capabilities.transports.pty_adapter = claude
2134            .capabilities
2135            .transports
2136            .pty_adapter
2137            .clone();
2138        spec.capabilities.adapters.resume = claude
2139            .capabilities
2140            .adapters
2141            .resume
2142            .clone();
2143        let script = spec
2144            .launch
2145            .fixed_args
2146            .pop()
2147            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2148        spec.launch.fixed_args.push(format!(
2149            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2150        ));
2151        Self::new_fixture_with_spec(config, spec)
2152    }
2153
2154    #[cfg(feature = "fixture")]
2155    pub fn new_cmd_cwd_fixture(
2156        config: NodeServerConfig,
2157        command: String,
2158    ) -> Result<Self, NodeServerError> {
2159        let mut spec = gate4agent_testkit::interactive_agent_spec();
2160        spec.id = AgentId::new("claude")
2161            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2163        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2164        spec.launch.program = command;
2165        spec.launch.fixed_args.clear();
2166        Self::new_fixture_with_spec(config, spec)
2167    }
2168
2169    #[cfg(feature = "fixture")]
2170    fn new_fixture_with_spec(
2171        config: NodeServerConfig,
2172        spec: gate4agent_types::AgentSpec,
2173    ) -> Result<Self, NodeServerError> {
2174        let catalog = AgentRegistry::new([spec])
2175            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2176        Self::new_with_registry(config, catalog)
2177    }
2178
2179    fn new_with_registry(
2180        config: NodeServerConfig,
2181        catalog: AgentRegistry,
2182    ) -> Result<Self, NodeServerError> {
2183        for profile in config.spawn_profiles.iter() {
2184            if catalog.get(&profile.provider).is_none() {
2185                return Err(NodeServerError::Registry(
2186                    "spawn profile references an unavailable provider".to_owned(),
2187                ));
2188            }
2189        }
2190        let input_settle_timeout_ms = catalog
2191            .iter()
2192            .map(|spec| spec.readiness.timeout_ms)
2193            .max()
2194            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2195            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2196            .max(MUTATION_SETTLE_TIMEOUT_MS);
2197        let (provider_contracts, provider_adapter_contracts) =
2198            provider_contract_manifest(&catalog)?;
2199        let compatibility_support = node_compatibility_support_for_manifest(
2200            &provider_contracts,
2201            &provider_adapter_contracts,
2202        )?;
2203        validate_node_negotiated_handshake_capacity(&compatibility_support)
2204            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2205        let enabled_providers = provider_contracts
2206            .iter()
2207            .map(|contract| contract.provider.clone())
2208            .collect();
2209        #[cfg(feature = "fixture")]
2210        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2211        #[cfg(not(feature = "fixture"))]
2212        let fixture_raw_pty_runtime = false;
2213        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2214            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2215                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2216            })).expect("fixture provider catalog remains bounded");
2217            (None, statuses)
2218        } else {
2219            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2220            let statuses = monitor.collect();
2221            (Some(monitor), statuses)
2222        };
2223        let state_path_lock =
2224            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2225                .map_err(|error| {
2226                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2227                })?;
2228        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2229            Some(state_path) => {
2230                let delivery_root = delivery_store_root_for_state_path(state_path)
2231                    .ok_or_else(|| {
2232                        NodeServerError::DeliveryStore(
2233                            "delivery store root could not be derived from state path".to_owned(),
2234                        )
2235                    })?;
2236                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2237                    let detail = match &error {
2238                        DeliveryStoreError::Storage(io_error) => {
2239                            format!("{error}: {io_error}")
2240                        }
2241                        _ => error.to_string(),
2242                    };
2243                    NodeServerError::DeliveryStore(detail)
2244                })?;
2245                (Some(store), bundles)
2246            }
2247            None => (None, Vec::new()),
2248        };
2249        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2250            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2251        })?;
2252        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2253            Some(state_path) => {
2254                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2255                    .ok_or(NodeServerError::ContextPackStore)?;
2256                let (store, packs) = ContextPackStore::open(context_pack_root)
2257                    .map_err(|_| NodeServerError::ContextPackStore)?;
2258                (Some(store), packs)
2259            }
2260            None => (None, Vec::new()),
2261        };
2262        let mut durable_context_catalog = ContextPackCatalog::default();
2263        for pack in durable_context_packs {
2264            durable_context_catalog
2265                .insert(pack)
2266                .map_err(|_| NodeServerError::ContextPackStore)?;
2267        }
2268        let session_environment_materializer = config
2269            .session_environment
2270            .as_ref()
2271            .map(|environment| {
2272                SessionEnvironmentMaterializer::new(
2273                    environment.root.clone(),
2274                    Arc::clone(&environment.resolver),
2275                )
2276                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2277            })
2278            .transpose()?;
2279        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2280        // (the provider version probe above already used the original catalog).
2281        #[cfg(unix)]
2282        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2283        let native_session_catalog = config.history.clone().map(|history| {
2284            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2285        });
2286        let (handle, runtime) = match config.history.clone() {
2287            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2288            None => NativeRuntime::new(catalog, config.runtime),
2289        };
2290        let native_launch_profile_control = runtime.native_launch_profile_control();
2291        let harness_mcp_registry = config
2292            .harness_mcp_helper
2293            .clone()
2294            .map(HarnessMcpProxyRegistry::new);
2295        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2296        let incarnation_id = random_incarnation_id()
2297            .map_err(NodeServerError::IncarnationIdentity)?;
2298        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2299            .map_err(durable_state_load_error)?;
2300        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2301        let managed_worktree_tombstones =
2302            std::mem::take(&mut loaded.managed_worktree_tombstones);
2303        let materializations = std::mem::take(&mut loaded.materializations);
2304        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2305        let (workspaces, records, persistence_warning) =
2306            merge_durable_state(&config.workspaces, loaded)?;
2307        let mut shared = NodeShared::new_with_incarnation(
2308            handle,
2309            config.access_token.clone(),
2310            config.node_id.clone(),
2311            incarnation_id,
2312            workspaces,
2313            enabled_providers,
2314            provider_runtime_statuses,
2315            provider_runtime_monitor,
2316            provider_contracts,
2317            provider_adapter_contracts,
2318            config.spawn_profiles.clone(),
2319            Some(native_launch_profile_control),
2320            native_session_catalog,
2321            session_environment_materializer,
2322            config.state_path.clone(),
2323            records,
2324            managed_worktrees,
2325            managed_worktree_tombstones,
2326            materializations,
2327            persistence_warning,
2328            input_settle_timeout_ms,
2329        );
2330        shared.harness_mcp_registry = harness_mcp_registry.clone();
2331        shared.session_record_retention = config.session_record_retention;
2332        shared.network_allowlist_catalog =
2333            RwLock::new(config.network_allowlist_catalog.clone());
2334        shared.bundle_catalog = RwLock::new(delivered_catalog);
2335        shared.delivery_store = Mutex::new(delivery_store);
2336        shared.context_catalog = RwLock::new(durable_context_catalog);
2337        shared.context_pack_store = Mutex::new(context_pack_store);
2338        shared.managed_spawn_replays = Mutex::new(
2339            managed_spawn_replays
2340                .into_iter()
2341                .map(|record| (record.idempotency_key.clone(), record))
2342                .collect(),
2343        );
2344        let shared = Arc::new(shared);
2345        if let Some(registry) = harness_mcp_registry {
2346            let weak = Arc::downgrade(&shared);
2347            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2348                Some(shared) => shared.publish_transient(event),
2349                None => false,
2350            }));
2351        }
2352        if shared.persistence_error().is_none() {
2353            shared.persist_state().map_err(|error| {
2354                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2355            })?;
2356        }
2357        Ok(Self {
2358            config,
2359            runtime,
2360            shared,
2361            events,
2362            state_path_lock,
2363        })
2364    }
2365
2366    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2367        NodeShutdownHandle {
2368            shared: Arc::clone(&self.shared),
2369        }
2370    }
2371
2372    #[cfg(feature = "fixture")]
2373    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2374        &self,
2375    ) -> SpawnManagedWorktreeV2FailureProbe {
2376        self.shared
2377            .fixture_spawn_managed_worktree_v2_failure_probe
2378            .clone()
2379    }
2380
2381    /// Installs one immutable host-local environment profile before the server runs.
2382    ///
2383    /// Native resolvers remain inside the runtime; the shared node registry keeps
2384    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2385    pub fn install_environment_profile(
2386        &mut self,
2387        profile: NodeEnvironmentProfile,
2388    ) -> Result<(), NodeServerError> {
2389        if !self.shared.enabled_providers.contains(profile.provider()) {
2390            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2391                profile.provider().clone(),
2392            ));
2393        }
2394        let (binding, native_profiles, materialization) = profile.into_parts();
2395        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2396            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2397        }
2398        let mut profiles = self
2399            .shared
2400            .environment_profiles
2401            .write()
2402            .unwrap_or_else(|poisoned| poisoned.into_inner());
2403        if profiles.contains_key(&binding.id) {
2404            return Err(NodeServerError::DuplicateEnvironmentProfile(
2405                binding.id.clone(),
2406            ));
2407        }
2408        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2409            return Err(NodeServerError::EnvironmentProfileCapacity {
2410                max: MAX_NODE_ENVIRONMENT_PROFILES,
2411            });
2412        }
2413        for native_id in binding.native_profile_ids() {
2414            if profiles.values().any(|current| {
2415                current.native_profile_ids().any(|current_id| current_id == native_id)
2416            }) {
2417                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2418                    native_id.to_string(),
2419                ));
2420            }
2421        }
2422
2423        let mut installed = Vec::with_capacity(native_profiles.len());
2424        for native_profile in native_profiles {
2425            let native_id = native_profile.id().clone();
2426            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2427                for installed_id in installed.into_iter().rev() {
2428                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2429                }
2430                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2431            }
2432            installed.push(native_id);
2433        }
2434        if let Some(materialization) = materialization {
2435            self.shared
2436                .environment_materialization_profiles
2437                .write()
2438                .unwrap_or_else(|poisoned| poisoned.into_inner())
2439                .insert(binding.id.clone(), materialization);
2440        }
2441        profiles.insert(binding.id.clone(), binding);
2442        Ok(())
2443    }
2444
2445    /// Installs one immutable host-local bundle before the server runs.
2446    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2447        if self.shared.session_environment_materializer.is_none() {
2448            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2449        }
2450        let mut catalog = self
2451            .shared
2452            .bundle_catalog
2453            .write()
2454            .unwrap_or_else(|poisoned| poisoned.into_inner());
2455        catalog
2456            .insert_idempotent(bundle)
2457            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2458        Ok(())
2459    }
2460
2461    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2462        let shutdown = self.shutdown_handle();
2463        let run = self.run();
2464        tokio::pin!(run);
2465        tokio::select! {
2466            result = &mut run => result,
2467            signal = wait_for_ctrl_signal() => {
2468                signal?;
2469                let shutdown_result = shutdown.request_shutdown().await;
2470                let run_result = run.await;
2471                shutdown_result.and(run_result)
2472            }
2473        }
2474    }
2475
2476    pub async fn run(self) -> Result<(), NodeServerError> {
2477        let NodeServer {
2478            config,
2479            mut runtime,
2480            shared,
2481            events,
2482            state_path_lock: _state_path_lock,
2483        } = self;
2484        shared.reconcile_materializations();
2485        shared.reconcile_managed_worktrees().await;
2486        shared.retire_unavailable_session_records().await;
2487        shared.reconcile_context_pack_exports().await;
2488        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2489        // observes sessions through ACP where a provider has it; there is no
2490        // hook ingress.
2491        let endpoint = config.endpoint.clone();
2492        let call_home = config.call_home;
2493        let call_home_identity = config.call_home_identity.clone();
2494        let api_listen = config.api_listen;
2495        let bridge_listen = config.bridge_listen;
2496        let bridge_underlay = config.bridge_underlay;
2497        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2498            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2499        let accept_shared = Arc::clone(&shared);
2500        let api_shared = Arc::clone(&shared);
2501        let bridge_shared = Arc::clone(&shared);
2502        let shutdown_shared = Arc::clone(&shared);
2503        let shutdown_timeout = Duration::from_millis(
2504            config.runtime.provider_shutdown_timeout_ms.max(1),
2505        );
2506        let result = {
2507            let runtime_loop = drive_runtime_until_shutdown(
2508                &mut runtime,
2509                events,
2510                Arc::clone(&shared),
2511                shutdown_timeout,
2512            );
2513            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2514            let api_loop = http_api::run(api_listen, api_shared);
2515            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2516            tokio::pin!(runtime_loop);
2517            tokio::pin!(accept_loop);
2518            tokio::pin!(api_loop);
2519            tokio::pin!(bridge_loop);
2520            tokio::select! {
2521                runtime_result = &mut runtime_loop => {
2522                    let shutdown_result = shutdown_shared
2523                        .begin_shutdown()
2524                        .await
2525                        .map_err(NodeServerError::ShutdownDispatch);
2526                    let (accept_result, api_result, bridge_result) =
2527                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2528                    match runtime_result {
2529                        Err(error) => Err(error),
2530                        Ok(()) => shutdown_result
2531                            .and(accept_result)
2532                            .and(api_result.map_err(NodeServerError::HttpApi))
2533                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2534                    }
2535                }
2536                accept_result = &mut accept_loop => {
2537                    let shutdown_result = shutdown_shared
2538                        .begin_shutdown()
2539                        .await
2540                        .map_err(NodeServerError::ShutdownDispatch);
2541                    let (runtime_result, api_result, bridge_result) =
2542                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2543                    match accept_result {
2544                        Err(error) => Err(error),
2545                        Ok(()) => shutdown_result
2546                            .and(runtime_result)
2547                            .and(api_result.map_err(NodeServerError::HttpApi))
2548                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2549                    }
2550                }
2551                api_result = &mut api_loop => {
2552                    let shutdown_result = shutdown_shared
2553                        .begin_shutdown()
2554                        .await
2555                        .map_err(NodeServerError::ShutdownDispatch);
2556                    let (runtime_result, accept_result, bridge_result) =
2557                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2558                    match api_result {
2559                        Err(error) => Err(NodeServerError::HttpApi(error)),
2560                        Ok(()) => shutdown_result
2561                            .and(runtime_result)
2562                            .and(accept_result)
2563                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2564                    }
2565                }
2566                bridge_result = &mut bridge_loop => {
2567                    let shutdown_result = shutdown_shared
2568                        .begin_shutdown()
2569                        .await
2570                        .map_err(NodeServerError::ShutdownDispatch);
2571                    let (runtime_result, accept_result, api_result) =
2572                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2573                    match bridge_result {
2574                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2575                        Ok(()) => shutdown_result
2576                            .and(runtime_result)
2577                            .and(accept_result)
2578                            .and(api_result.map_err(NodeServerError::HttpApi)),
2579                    }
2580                }
2581            }
2582        };
2583        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2584        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2585        // NodeShutdownHandle also clones that Arc, so without an explicit release
2586        // here a supervisor that keeps the shutdown handle cannot reopen the same
2587        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2588        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2589        if let Ok(mut guard) = shared.delivery_store.lock() {
2590            *guard = None;
2591        }
2592        if let Ok(mut guard) = shared.context_pack_store.lock() {
2593            *guard = None;
2594        }
2595        result
2596    }
2597}
2598
2599async fn drive_runtime_until_shutdown(
2600    runtime: &mut NativeRuntime,
2601    events: EventSubscription,
2602    shared: Arc<NodeShared>,
2603    shutdown_timeout: Duration,
2604) -> Result<(), NodeServerError> {
2605    // Published once, never per iteration. The worker loops inside
2606    // `NativeRuntime` fold into this profile continuously; turning it into
2607    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2608    let _ = shared
2609        .shell_efficiency_profile
2610        .set(runtime.shell_efficiency_profile());
2611    let mut shutdown_started = None;
2612    let mut last_retention_sweep = Instant::now();
2613    loop {
2614        // Every phase below is timed unconditionally -- see
2615        // `drive_loop_profile`'s own doc comment for why an always-on,
2616        // allocation-free ring is cheap enough to never need a feature
2617        // flag. This is the only place these numbers can be taken: the
2618        // phases are calls made directly from this loop, not something a
2619        // callee could time on our behalf.
2620        let runtime_tick_start = Instant::now();
2621        let tick_result = runtime.tick().await;
2622        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2623
2624        let event_drain_start = Instant::now();
2625        // Every command the kernel refused, named. `NativeRuntimeTick`
2626        // has carried these outcomes all along and nothing read them, so a
2627        // rejected command -- a hook event denied by runtime policy, a
2628        // stale generation, an unsupported capability -- vanished at the
2629        // last step of the loop that produced it. A refusal the operator
2630        // cannot see is indistinguishable from an event that never arrived,
2631        // and telling those two apart is most of debugging this stack.
2632        for outcome in &tick_result.command_outcomes {
2633            if let Err(error) = &outcome.result {
2634                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2635                tracing::warn!(
2636                    command_id = outcome.command_id.0,
2637                    cause = %error,
2638                    "control command rejected"
2639                );
2640                shared.record_rejected_command(
2641                    outcome.command_id,
2642                    error.to_string(),
2643                    error.is_unsupported_transport(),
2644                );
2645            }
2646        }
2647        let mut events_drained: u64 = 0;
2648        while let Ok(event) = events.try_recv() {
2649            events_drained += 1;
2650            let clean_exit = matches!(
2651                event.event,
2652                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2653            );
2654            let instance_id = event.instance_id;
2655            let generation = event.generation;
2656            // Resolve and spawn before publish_control: publish_control's own
2657            // managed-record bookkeeping (reconcile_managed_record) downgrades
2658            // this exact record from Live to Dormant and clears
2659            // active_session for every Exited event, inside the same
2660            // synchronous call. Capturing (record_id, session) here — before
2661            // that runs — is the only way to observe them still Live and
2662            // still populated; the export itself must still run on its own
2663            // detached task (never awaited inline on this loop) because it
2664            // needs NativeRuntime::tick() to keep running to ever settle, and
2665            // tick() is only ever driven by this same loop. See
2666            // reconcile_context_pack_export_for_record's doc comment.
2667            if clean_exit {
2668                if let Some((record_id, session)) =
2669                    shared.managed_record_export_target_for_instance(instance_id, generation)
2670                {
2671                    let export_shared = Arc::clone(&shared);
2672                    tokio::spawn(async move {
2673                        export_shared
2674                            .reconcile_context_pack_export_for_record(&record_id, session)
2675                            .await;
2676                    });
2677                }
2678            }
2679            shared.publish_control(event);
2680        }
2681        let event_drain_elapsed = event_drain_start.elapsed();
2682
2683        let publish_terminal_frames_start = Instant::now();
2684        shared.publish_terminal_frames();
2685        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2686
2687        // Physical PTY count and the runtime's own per-phase tick timings
2688        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2689        // only `NodeShared`) cannot reach directly -- this loop is the one
2690        // place holding `&mut NativeRuntime`, so it is the only place that
2691        // can copy either out.
2692        shared
2693            .native_session_gauge
2694            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2695        *shared
2696            .runtime_tick_profile
2697            .lock()
2698            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2699
2700        let remainder_start = Instant::now();
2701        if shared.shutdown.load(Ordering::Acquire) {
2702            let started = *shutdown_started.get_or_insert_with(Instant::now);
2703            let snapshot = shared.handle.snapshot();
2704            let sessions_settled = snapshot.sessions.iter().all(|session| {
2705                !matches!(
2706                    session.status,
2707                    gate4agent_types::SessionStatus::Starting
2708                        | gate4agent_types::SessionStatus::Running
2709                        | gate4agent_types::SessionStatus::Stopping
2710                )
2711            });
2712            if runtime.active_native_sessions() == 0 && sessions_settled {
2713                return Ok(());
2714            }
2715            if started.elapsed() >= shutdown_timeout {
2716                return Err(NodeServerError::ShutdownTimedOut {
2717                    active_native_sessions: runtime.active_native_sessions(),
2718                });
2719            }
2720        }
2721        let remainder_elapsed = remainder_start.elapsed();
2722
2723        {
2724            let mut profile = shared
2725                .drive_loop_profile
2726                .lock()
2727                .unwrap_or_else(|poisoned| poisoned.into_inner());
2728            profile.record_runtime_tick(runtime_tick_elapsed);
2729            profile.record_event_drain(event_drain_elapsed);
2730            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2731            profile.record_remainder(remainder_elapsed);
2732            profile.note_iteration(Instant::now());
2733        }
2734
2735        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2736        // applied zero observations (`tick_result.observations_applied`,
2737        // which already folds in any terminal frames collected -- see
2738        // `NativeEffectDispatcher::drain_observations`) AND the
2739        // control-event subscription drained above forwarded zero events.
2740        // Command ingress and dispatched effects are deliberately NOT part
2741        // of this definition: an idle node still runs `step_control_plane`
2742        // and every provider supervisor's `tick` regardless, every 10ms --
2743        // so "nothing" here means nothing OBSERVABLE left this iteration,
2744        // matching what a reader would assume from `drive_loop_iterations`'s
2745        // own field name, not that the tick itself was free of work.
2746        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2747        if tick_result.observations_applied == 0 && events_drained == 0 {
2748            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2749        }
2750
2751        // A sweep cadence, not a policy value -- see
2752        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2753        // policy itself (whether this does anything at all) lives entirely
2754        // in `NodeShared::session_record_retention`.
2755        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2756            shared.retire_unavailable_session_records().await;
2757            last_retention_sweep = Instant::now();
2758        }
2759
2760        // Fast-poll for 300ms after PTY input so its echo is read and published
2761        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2762        let hot = shared
2763            .pty_input_marks
2764            .lock()
2765            .unwrap_or_else(|poisoned| poisoned.into_inner())
2766            .values()
2767            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2768        if hot {
2769            sleep(Duration::from_millis(1)).await;
2770        } else {
2771            tokio::select! {
2772                _ = sleep(Duration::from_millis(10)) => {}
2773                _ = shared.pty_input_wake.notified() => {}
2774            }
2775        }
2776    }
2777}
2778
2779#[derive(Clone)]
2780pub struct NodeShutdownHandle {
2781    shared: Arc<NodeShared>,
2782}
2783
2784impl NodeShutdownHandle {
2785    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2786        self.shared
2787            .begin_shutdown()
2788            .await
2789            .map_err(NodeServerError::ShutdownDispatch)
2790    }
2791}
2792
2793async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2794    #[cfg(windows)]
2795    {
2796    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2797    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2798    return tokio::select! {
2799        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2800        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2801    };
2802    }
2803    #[cfg(unix)]
2804    {
2805        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2806        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2807        tokio::select! {
2808            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2809            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2810        }
2811    }
2812}
2813
2814/// The providers this node actually runs.
2815///
2816/// `builtin_registry()` is a reference catalog of everything the project has
2817/// ever described; the fleet is this list, and it is deliberately short. A
2818/// provider only belongs here if it can be exercised for real -- there is a
2819/// subscription behind it and a failure in it can be reproduced and
2820/// debugged. Carrying one that cannot is carrying code nobody can verify,
2821/// which is worse than not supporting it: it looks supported.
2822///
2823/// A provider dropped from the fleet by this rule is removed outright, not
2824/// carried dark: no catalog entry, no adapter, no dead code claiming support
2825/// for something nobody can verify runs.
2826/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2827fn shell_host_spec() -> AgentSpec {
2828    AgentSpec {
2829        id: AgentId::new("shell").expect("shell agent id"),
2830        revision: "hatchery:shell-pty-1".to_owned(),
2831        display_name: "Host shell".to_owned(),
2832        detection: DetectionSpec {
2833            command: "bash".to_owned(),
2834            aliases: vec!["sh".to_owned(), "shell".to_owned()],
2835            required_commands: Vec::new(),
2836            unsupported_platforms: Vec::new(),
2837        },
2838        launch: LaunchSpec {
2839            // Absolute path so admission does not depend on PATH.
2840            program: "/bin/bash".to_owned(),
2841            fixed_args: vec!["-i".to_owned()],
2842        },
2843        expected_processes: vec![ProcessMatcher::Exact {
2844            name: "bash".to_owned(),
2845        }],
2846        prompt: PromptSpec {
2847            initial: InitialPromptMode::None,
2848            native_draft: None,
2849        },
2850        readiness: AgentReadinessSpec::default(),
2851        capabilities: AgentCapabilities {
2852            agent_commands: None,
2853            transports: AgentTransportCapabilities {
2854                pty: true,
2855                pty_adapter: None,
2856                pipe: None,
2857                acp: None,
2858            },
2859            adapters: AgentAdapterCapabilities::default(),
2860        },
2861        verification: SpecVerification::Gate4AgentVerified,
2862    }
2863}
2864
2865fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2866    let mut specs = vec![shell_host_spec()];
2867    specs.extend(
2868        builtin_registry()
2869            .iter()
2870            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2871            .cloned(),
2872    );
2873    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2874}
2875
2876fn provider_contract_manifest(
2877    catalog: &AgentRegistry,
2878) -> Result<
2879    (
2880        Vec<ProviderContractSupport>,
2881        Vec<ProviderAdapterContractSupport>,
2882    ),
2883    NodeServerError,
2884> {
2885    let mut provider_contracts = Vec::new();
2886    let mut provider_adapter_contracts = Vec::new();
2887    for spec in catalog.iter() {
2888        let provider = spec.id.clone();
2889        let revision = ProviderContractRevision::new(spec.revision.clone())
2890            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2891        provider_contracts.push(ProviderContractSupport {
2892            provider: provider.clone(),
2893            revision,
2894        });
2895        for (family, binding) in declared_adapter_bindings(spec)? {
2896            let revision = AdapterContractRevision::new(binding.revision.clone())
2897                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2898            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2899                provider: provider.clone(),
2900                family,
2901                adapter_id: binding.id.clone(),
2902                revision,
2903            });
2904        }
2905    }
2906    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2907        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2908    Ok((provider_contracts, provider_adapter_contracts))
2909}
2910
2911fn declared_adapter_bindings(
2912    spec: &AgentSpec,
2913) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2914    let mut bindings = Vec::new();
2915    push_declared_binding(
2916        &mut bindings,
2917        AdapterFamily::PtySemantic,
2918        spec.capabilities.transports.pty_adapter.as_ref(),
2919    )?;
2920    push_declared_binding(
2921        &mut bindings,
2922        AdapterFamily::Pipe,
2923        spec.capabilities.adapters.pty_sidecar.as_ref(),
2924    )?;
2925    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2926    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2927        gate4agent_types::PipeProtocol::SemanticNdjson
2928        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2929        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2930    });
2931    push_declared_binding(
2932        &mut bindings,
2933        AdapterFamily::Pipe,
2934        pipe_transport
2935            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2936            .map(|transport| &transport.adapter),
2937    )?;
2938    push_declared_binding(
2939        &mut bindings,
2940        AdapterFamily::Acp,
2941        spec.capabilities
2942            .transports
2943            .acp
2944            .as_ref()
2945            .map(|transport| &transport.adapter),
2946    )?;
2947    push_declared_binding(
2948        &mut bindings,
2949        AdapterFamily::Hook,
2950        spec.capabilities.adapters.hook.as_ref(),
2951    )?;
2952    push_declared_binding(
2953        &mut bindings,
2954        AdapterFamily::ManagedHook,
2955        spec.capabilities.adapters.managed_hook.as_ref(),
2956    )?;
2957    push_declared_binding(
2958        &mut bindings,
2959        AdapterFamily::OneShot,
2960        pipe_transport
2961            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2962            .map(|transport| &transport.adapter),
2963    )?;
2964    push_declared_binding(
2965        &mut bindings,
2966        AdapterFamily::OneShot,
2967        spec.capabilities.adapters.one_shot.as_ref(),
2968    )?;
2969    push_declared_binding(
2970        &mut bindings,
2971        AdapterFamily::History,
2972        spec.capabilities.adapters.history.as_ref(),
2973    )?;
2974    push_declared_binding(
2975        &mut bindings,
2976        AdapterFamily::Resume,
2977        spec.capabilities.adapters.resume.as_ref(),
2978    )?;
2979    push_declared_binding(
2980        &mut bindings,
2981        AdapterFamily::SessionOptions,
2982        spec.capabilities.adapters.session_options.as_ref(),
2983    )?;
2984    push_declared_binding(
2985        &mut bindings,
2986        AdapterFamily::CapabilityProbe,
2987        spec.capabilities.adapters.capability_probe.as_ref(),
2988    )?;
2989    Ok(bindings)
2990}
2991
2992fn push_declared_binding<'a>(
2993    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2994    family: AdapterFamily,
2995    binding: Option<&'a AdapterBinding>,
2996) -> Result<(), NodeServerError> {
2997    let Some(binding) = binding else {
2998        return Ok(());
2999    };
3000    if let Some((_, existing)) = bindings
3001        .iter()
3002        .find(|(existing_family, _)| *existing_family == family)
3003    {
3004        if *existing == binding {
3005            return Ok(());
3006        }
3007        return Err(NodeServerError::ProviderContractManifest(format!(
3008            "provider declares conflicting {family:?} adapter bindings"
3009        )));
3010    }
3011    bindings.push((family, binding));
3012    Ok(())
3013}
3014
3015fn merge_durable_state(
3016    configured: &[WorkspaceConfig],
3017    mut loaded: LoadedNodeState,
3018) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3019    let mut workspaces = configured
3020        .iter()
3021        .cloned()
3022        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3023        .collect::<BTreeMap<_, _>>();
3024    let mut roots = workspaces
3025        .values()
3026        .map(|workspace| {
3027            (
3028                platform::root_identity(&workspace.canonical_root),
3029                workspace.workspace_id.clone(),
3030            )
3031        })
3032        .collect::<BTreeMap<_, _>>();
3033    let mut warning = loaded
3034        .warning
3035        .take()
3036        .map(|message| session_registry::sanitized_persistence_summary(&message));
3037
3038    for (workspace_id, canonical_root) in loaded.workspaces {
3039        if let Some(existing) = workspaces.get(&workspace_id) {
3040            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3041                return Err(NodeServerError::DurableState(io::Error::new(
3042                    io::ErrorKind::InvalidData,
3043                    DURABLE_STATE_CONFLICT_ERROR,
3044                )));
3045            }
3046            continue;
3047        }
3048        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3049            return Err(NodeServerError::DurableState(io::Error::new(
3050                io::ErrorKind::InvalidData,
3051                DURABLE_STATE_CONFLICT_ERROR,
3052            )));
3053        }
3054        if !Path::new(&canonical_root).is_dir() {
3055            warning.get_or_insert_with(|| {
3056                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3057            });
3058            continue;
3059        }
3060        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3061            NodeServerError::DurableState(io::Error::new(
3062                io::ErrorKind::InvalidData,
3063                DURABLE_STATE_CONFLICT_ERROR,
3064            ))
3065        })?;
3066        roots.insert(
3067            platform::root_identity(&workspace.canonical_root),
3068            workspace.workspace_id.clone(),
3069        );
3070        workspaces.insert(workspace_id, workspace);
3071    }
3072
3073    for record in &mut loaded.records {
3074        match workspaces.get(&record.workspace_id) {
3075            Some(workspace)
3076                if platform::roots_equal(
3077                    &workspace.canonical_root,
3078                    windows_path_text(&record.canonical_root),
3079                ) => {}
3080            Some(_) => {
3081                return Err(NodeServerError::DurableState(io::Error::new(
3082                    io::ErrorKind::InvalidData,
3083                    DURABLE_STATE_CONFLICT_ERROR,
3084                )));
3085            }
3086            None => {
3087                record.state = ManagedSessionState::Unavailable;
3088                record.active_session = None;
3089                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3090            }
3091        }
3092    }
3093    Ok((workspaces.into_values().collect(), loaded.records, warning))
3094}
3095
3096/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3097/// own doc for the mechanism this exists to close.
3098struct RejectedCommandReason {
3099    message: String,
3100    /// True when the kernel refused this command specifically because the
3101    /// agent does not declare the requested transport (`KernelCommandError::
3102    /// UnsupportedTransport`) -- lets the reader choose the named
3103    /// `NodeFailureCode::UnsupportedTransport` over the generic
3104    /// `BackendOperationFailed` bucket, so this one well-known rejection
3105    /// keeps its identity all the way to the operator wire.
3106    unsupported_transport: bool,
3107    expires_at: Instant,
3108}
3109
3110struct NodeShared {
3111    handle: Gate4AgentHandle,
3112    access_token: String,
3113    node_id: NodeId,
3114    incarnation_id: NodeIncarnationId,
3115    started_at_unix_ms: u64,
3116    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3117    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3118    managed_worktree_profiles:
3119        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3120    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3121    enabled_providers: Vec<AgentId>,
3122    provider_runtime_statuses: ProviderRuntimeStatuses,
3123    provider_runtime_status_updates:
3124        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3125    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3126    provider_contracts: Vec<ProviderContractSupport>,
3127    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3128    spawn_profiles: SpawnProfileRegistry,
3129    environment_profiles:
3130        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3131    environment_materialization_profiles:
3132        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3133    /// Station **network** allowlist catalog (opaque policy ids only).
3134    /// Empty by default — any `network_allowlist` on spawn must be registered
3135    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3136    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3137    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3138    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3139    /// Never holds cookies / OAuth / proxy credentials.
3140    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3141    /// Node-local exclusive dig2browser station profile leases (feature
3142    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3143    /// cleared on session end. Never ImportSession / cookies on C2.
3144    #[cfg(feature = "dig2-station-probe")]
3145    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3146    bundle_catalog: RwLock<BundleCatalog>,
3147    delivery_store: Mutex<Option<DeliveryStore>>,
3148    context_pack_store: Mutex<Option<ContextPackStore>>,
3149    context_catalog: RwLock<ContextPackCatalog>,
3150    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3151    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3152    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3153    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3154    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3155    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3156    /// nick -> the session that holds that m4a account (for letter wakes).
3157    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3158    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3159    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3160    managed_spawn_replays:
3161        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3162    controller: Mutex<Option<ControllerLease>>,
3163    history: Mutex<NodeEventHistory>,
3164    event_tx: broadcast::Sender<NodeEventEnvelope>,
3165    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3166    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3167    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3168    /// exactly rather than riding the durable `event_tx` history (see
3169    /// `publish`'s own assertion): a subscriber who never negotiated
3170    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3171    /// these, the same guarantee `include_terminal_frame_events` already
3172    /// gives terminal frames.
3173    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3174    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3175    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3176    /// broadcast rather than a ride on the durable `event_tx` history. A
3177    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3178    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3179    /// finite capacity and its willingness to silently evict a lagging
3180    /// subscriber's backlog is exactly how the call vanished with neither
3181    /// side ever logging it (see `publish_transient`'s own doc).
3182    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3183    terminal_frame_watermarks:
3184        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3185    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3186    pty_input_wake: tokio::sync::Notify,
3187    /// Per session: when the last PTY input was dispatched (latency probe +
3188    /// a short fast-poll window so the echo is published within ~1ms).
3189    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3190    next_connection_id: AtomicU64,
3191    next_instance_id: AtomicU64,
3192    next_command_id: AtomicU64,
3193    shutdown: AtomicBool,
3194    shutdown_notify: Notify,
3195    preauth_slots: Arc<Semaphore>,
3196    authenticated_slots: Arc<Semaphore>,
3197    inspection_slots: Arc<Semaphore>,
3198    mutation_gate: AsyncMutex<()>,
3199    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3200    session_records: Mutex<SessionRecords>,
3201    state_path: Option<PathBuf>,
3202    persistence_error: RwLock<Option<String>>,
3203    state_transaction: Mutex<()>,
3204    input_settle_timeout_ms: u64,
3205    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3206    /// comment. Written once per iteration by
3207    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3208    drive_loop_profile: Mutex<DriveLoopProfiler>,
3209    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3210    /// so an overwrite each iteration is cheap. The runtime itself is not
3211    /// reachable from here (it is owned locally by
3212    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3213    /// tick timings reach `GET /metrics`.
3214    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3215    /// The live shell-efficiency profile, shared with the per-instance
3216    /// worker loops that fold into it.
3217    ///
3218    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3219    /// profile` above keeps: producing a snapshot sorts every ring behind
3220    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3221    /// field set at construction because `NodeShared` is built before the
3222    /// `NativeRuntime` that owns the profile exists -- the drive loop
3223    /// publishes it once, before its first iteration. Unset means the
3224    /// runtime has not started driving yet, which `GET /metrics` reports as
3225    /// empty series rather than as zeroes it never measured.
3226    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3227    /// Physical PTY sessions the native runtime currently owns
3228    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3229    /// loop iteration -- deliberately a separate count from
3230    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3231    /// session bookkeeping), since the two can disagree and the gap is
3232    /// itself a diagnostic signal `GET /metrics` exposes.
3233    native_session_gauge: AtomicUsize,
3234    /// Lifetime totals for the per-connection serve loop, summed across
3235    /// every live connection.
3236    ///
3237    /// The drive loop's own phase timings account for about ten
3238    /// microseconds an iteration on an idle node, yet the process burns a
3239    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3240    /// with none, 23% with two, measured). That gap is somewhere in
3241    /// serving a connection, and there was no way to see it: iterations
3242    /// says whether the loop is spinning rather than sleeping, and events
3243    /// says whether it is spinning over real work or over nothing.
3244    connection_loop_iterations: AtomicU64,
3245    connection_events_sent: AtomicU64,
3246    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3247    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3248    /// `NativeRuntime::tick` observations AND forwarded zero control
3249    /// events; see the exact "produced nothing" definition at the site in
3250    /// `drive_runtime_until_shutdown` that increments these. Read twice
3251    /// and divide by the interval, same rule as `connections` above --
3252    /// `idle / total` is backlog item 5's "wake on work" number.
3253    /// Lifetime count of control commands the kernel refused.
3254    rejected_commands_total: AtomicU64,
3255    /// The named reason behind a recent kernel rejection, by the
3256    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3257    /// command -- populated by `drive_runtime_until_shutdown` for every
3258    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3259    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3260    /// `CommandId` among them.
3261    ///
3262    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3263    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3264    /// problem -- full or disconnected -- never for the kernel refusing the
3265    /// command's content (an unsupported transport, an unknown agent, ...);
3266    /// that verdict exists only once `NativeRuntime::tick` has processed the
3267    /// command, one drive-loop iteration later, in a different task than
3268    /// whichever caller is awaiting the spawn. Without this map, a spawn
3269    /// wait loop that polls `self.handle.snapshot()` for a session that
3270    /// will now never appear has no way to learn why -- it can only spin
3271    /// until its own commit deadline and report a generic "did not commit"
3272    /// timeout, discarding a reason the kernel already named precisely (see
3273    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3274    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3275    drive_loop_iterations_total: AtomicU64,
3276    drive_loop_iterations_idle: AtomicU64,
3277    /// The operator-chosen age/keep-N policy the retention sweep applies to
3278    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3279    /// see `SessionRecordRetentionConfig`'s own doc comment.
3280    session_record_retention: SessionRecordRetentionConfig,
3281    /// Lifetime count of managed session records the retention sweep has
3282    /// retired. Same tier as `rejected_commands_total` above: a live,
3283    /// process-lifetime instrument, never persisted and never reloaded from
3284    /// `state-v1.json` -- a restart legitimately resets it, matching every
3285    /// other `*_total` counter here.
3286    retired_records_total: AtomicU64,
3287    #[cfg(feature = "fixture")]
3288    fixture_semantic_hook_policy: bool,
3289    #[cfg(feature = "fixture")]
3290    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3291}
3292
3293impl Drop for NodeShared {
3294    fn drop(&mut self) {
3295        SessionLifecycleHook::on_shutdown(self);
3296    }
3297}
3298
3299#[derive(Clone, Debug, Eq, PartialEq)]
3300struct SessionBinding {
3301    workspace_id: WorkspaceId,
3302    generation: SessionGeneration,
3303    runtime_policy: ProviderRuntimePolicy,
3304    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3305    record_id: Option<SessionRecordId>,
3306    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3307    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3308    bundle: Option<ResolvedBundleReceipt>,
3309    context: Option<ResolvedContextPackReceipt>,
3310    materialization_id: Option<MaterializationId>,
3311}
3312
3313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3314enum SpawnRecordPolicy {
3315    ProviderIdentityOnly,
3316    Always,
3317}
3318
3319struct NativeEnvironmentSelectionGuard {
3320    control: Option<NativeLaunchProfileControl>,
3321    instance_id: AgentInstanceId,
3322}
3323
3324struct NativeInstanceOverlayGuard {
3325    control: Option<NativeLaunchProfileControl>,
3326    instance_id: AgentInstanceId,
3327}
3328
3329struct NativeHarnessMcpOverlayGuard {
3330    control: NativeLaunchProfileControl,
3331    instance_id: AgentInstanceId,
3332}
3333
3334impl NativeHarnessMcpOverlayGuard {
3335    fn retain(self) { std::mem::forget(self); }
3336}
3337
3338impl Drop for NativeHarnessMcpOverlayGuard {
3339    fn drop(&mut self) {
3340        self.control
3341            .clear_native_mcp_server_launch_overlay(self.instance_id);
3342    }
3343}
3344
3345impl NativeInstanceOverlayGuard {
3346    fn retain(mut self) {
3347        self.control = None;
3348    }
3349}
3350
3351impl Drop for NativeInstanceOverlayGuard {
3352    fn drop(&mut self) {
3353        if let Some(control) = self.control.as_ref() {
3354            control.clear_native_instance_launch_overlay(self.instance_id);
3355        }
3356    }
3357}
3358
3359enum PreparedNativeLaunchOverlay {
3360    Environment(NativeLaunchEnvironmentOverlay),
3361    Instance(NativeInstanceLaunchOverlay),
3362}
3363
3364impl NativeEnvironmentSelectionGuard {
3365    fn retain(mut self) {
3366        self.control = None;
3367    }
3368}
3369
3370impl Drop for NativeEnvironmentSelectionGuard {
3371    fn drop(&mut self) {
3372        if let Some(control) = self.control.as_ref() {
3373            control.clear_native_launch_profile_selection(self.instance_id);
3374        }
3375    }
3376}
3377
3378struct SessionMaterializationGuard<'a> {
3379    shared: &'a NodeShared,
3380    id: Option<MaterializationId>,
3381    /// Absolute ProviderHome (`home/`) under the session materialization root.
3382    /// Used for Claude station network `--settings` sidecar writes.
3383    provider_home: Option<PathBuf>,
3384}
3385
3386impl SessionMaterializationGuard<'_> {
3387    fn id(&self) -> Option<&MaterializationId> {
3388        self.id.as_ref()
3389    }
3390
3391    fn provider_home(&self) -> Option<&Path> {
3392        self.provider_home.as_deref()
3393    }
3394
3395    fn retain(mut self) {
3396        self.id = None;
3397    }
3398}
3399
3400impl Drop for SessionMaterializationGuard<'_> {
3401    fn drop(&mut self) {
3402        if let Some(id) = self.id.take() {
3403            let _ = self.shared.cleanup_materialization(&id);
3404        }
3405    }
3406}
3407
3408
3409/// Port: product-side reactions to session lifecycle transitions
3410/// (station lease, harness MCP overlay/registry, environment launch overlays).
3411trait SessionLifecycleHook {
3412    /// A binding was removed (unbind or spawn rollback).
3413    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3414    /// A session reached a terminal control event that revokes its harness MCP door.
3415    fn on_session_ended(&self, address: &SessionAddress);
3416    /// Node shutdown (or drop): revoke every harness MCP reservation.
3417    fn on_shutdown(&self);
3418    /// A harness MCP reservation was aborted before activation.
3419    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3420    /// Release session-owned materialization for a removed binding.
3421    fn cleanup_session_materialization(
3422        &self,
3423        address: &SessionAddress,
3424        binding: &SessionBinding,
3425    ) -> Result<(), NodeFailure>;
3426}
3427
3428impl SessionLifecycleHook for NodeShared {
3429    fn on_session_ended(&self, address: &SessionAddress) {
3430        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3431            registry.revoke_session(address);
3432        }
3433        if let Some(control) = self.native_launch_profile_control.as_ref() {
3434            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3435        }
3436    }
3437
3438    fn on_shutdown(&self) {
3439        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3440            let instances = registry.shutdown();
3441            if let Some(control) = self.native_launch_profile_control.as_ref() {
3442                for instance_id in instances {
3443                    control.clear_native_mcp_server_launch_overlay(instance_id);
3444                }
3445            }
3446        }
3447    }
3448
3449    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3450        if let Some(control) = self.native_launch_profile_control.as_ref() {
3451            control.clear_native_mcp_server_launch_overlay(instance_id);
3452        }
3453    }
3454
3455    fn cleanup_session_materialization(
3456        &self,
3457        address: &SessionAddress,
3458        binding: &SessionBinding,
3459    ) -> Result<(), NodeFailure> {
3460        self.cleanup_session_owned_materialization(address, binding)
3461    }
3462
3463    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3464        // Exclusive dig2browser station profile lease is node-local; clear
3465        // on every session unbind / rollback. Never ImportSession/cookies.
3466        #[cfg(feature = "dig2-station-probe")]
3467        {
3468            crate::dig2_station_lease::release_lease_for_holder(
3469                &self.browser_station_leases,
3470                address.session.instance_id,
3471            );
3472        }
3473        if let Some(control) = self.native_launch_profile_control.as_ref() {
3474            control.clear_native_mcp_server_launch_overlay(
3475                address.session.instance_id,
3476            );
3477        }
3478        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3479            registry.revoke_session(address);
3480        }
3481        if removed
3482            .is_some_and(|binding| {
3483                binding.environment_profile.is_some()
3484                    || binding.bundle.is_some()
3485                    || binding.context.is_some()
3486            })
3487        {
3488            if let Some(control) = self.native_launch_profile_control.as_ref() {
3489                control.clear_native_launch_profile_selection(
3490                    address.session.instance_id,
3491                );
3492                control.clear_native_instance_launch_overlay(
3493                    address.session.instance_id,
3494                );
3495            }
3496        }
3497    }
3498}
3499
3500struct SpawnPrepareRequest<'r> {
3501    instance_id: AgentInstanceId,
3502    address: &'r SessionAddress,
3503    provider: &'r AgentId,
3504    mode: SessionMode,
3505    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3506    bundle: Option<&'r ResolvedBundleReceipt>,
3507    context: Option<&'r ResolvedContextPackReceipt>,
3508    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3509    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3510    approval_level: ApprovalLevel,
3511    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3512    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3513}
3514
3515/// Guards held between "session identity minted" and "launch committed".
3516/// Field order is the drop order and matches the pre-refactor local order
3517/// (instance overlay, environment selection, materialization, harness, station lease).
3518struct PreparedSpawn<'a> {
3519    instance_overlay: Option<NativeInstanceOverlayGuard>,
3520    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3521    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3522    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3523    #[cfg(feature = "dig2-station-probe")]
3524    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3525    materialization_id: Option<MaterializationId>,
3526}
3527
3528impl PreparedSpawn<'_> {
3529    /// Session-owned launch state now belongs to the binding.
3530    fn retain_session_guards(&mut self) {
3531        if let Some(selection) = self.environment_selection.take() {
3532            selection.retain();
3533        }
3534        if let Some(overlay) = self.instance_overlay.take() {
3535            overlay.retain();
3536        }
3537        if let Some(guard) = self.materialization_guard.take() {
3538            guard.retain();
3539        }
3540    }
3541
3542    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3543    fn retain_launch_guards(&mut self) {
3544        if let Some(overlay) = self.harness_mcp_overlay.take() {
3545            overlay.retain();
3546        }
3547        #[cfg(feature = "dig2-station-probe")]
3548        if let Some(guard) = self.browser_station_lease_guard.take() {
3549            guard.retain();
3550        }
3551    }
3552}
3553
3554/// Port: everything product-side that must be prepared before a session is
3555/// bound and started (station lease, harness MCP overlay, materialization,
3556/// environment selection, network overlays, launch overlay).
3557trait SpawnPreparer {
3558    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3559    /// Select the native launch profile for an environment-profile session.
3560    fn select_environment(
3561        &self,
3562        instance_id: AgentInstanceId,
3563        provider: &AgentId,
3564        mode: SessionMode,
3565        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3566    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3567    /// Install a prepared launch overlay for the instance.
3568    fn install_launch_overlay(
3569        &self,
3570        instance_id: AgentInstanceId,
3571        overlay: PreparedNativeLaunchOverlay,
3572    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3573}
3574
3575impl SpawnPreparer for NodeShared {
3576    fn select_environment(
3577        &self,
3578        instance_id: AgentInstanceId,
3579        provider: &AgentId,
3580        mode: SessionMode,
3581        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3582    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3583        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3584    }
3585
3586    fn install_launch_overlay(
3587        &self,
3588        instance_id: AgentInstanceId,
3589        overlay: PreparedNativeLaunchOverlay,
3590    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3591        self.install_prepared_launch_overlay(instance_id, overlay)
3592    }
3593
3594    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3595        let SpawnPrepareRequest {
3596            instance_id,
3597            address,
3598            provider,
3599            mode,
3600            environment_profile,
3601            bundle,
3602            context,
3603            managed_authority,
3604            harness_mcp,
3605            approval_level,
3606            network_allowlist,
3607            browser_profile_id,
3608        } = request;
3609        // Track A follow-on: exclusive node-local BrowserStationLease after
3610        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3611        // never ImportSession / cookies on C2. Linux/feature-off: probe
3612        // already refused or stub; this block is cfg'd out when feature off.
3613        #[cfg(feature = "dig2-station-probe")]
3614        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3615            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3616            match acquire_lease(
3617                &self.browser_station_leases,
3618                profile_id,
3619                instance_id,
3620                unix_time_ms(),
3621            ) {
3622                Ok(guard) => Some(guard),
3623                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3624                    return Err(failure(
3625                        NodeFailureCode::BrowserStationProfileBusy,
3626                        "dig2browser station profile is already leased by another session",
3627                    ));
3628                }
3629                Err(BrowserStationLeaseError::CapacityExceeded) => {
3630                    return Err(failure(
3631                        NodeFailureCode::BrowserStationProfileBusy,
3632                        "dig2browser station profile lease capacity exhausted",
3633                    ));
3634                }
3635                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3636                    return Err(failure(
3637                        NodeFailureCode::BrowserStationProfileBusy,
3638                        "session already holds a dig2browser station profile lease",
3639                    ));
3640                }
3641            }
3642        } else {
3643            None
3644        };
3645        #[cfg(not(feature = "dig2-station-probe"))]
3646        let _ = browser_profile_id;
3647        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3648            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3649            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3650            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3651            // both read the identical overlay resolved below, one as child
3652            // environment, the other translated into one stdio MCP server
3653            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3654            // only, and nothing downstream reads a harness-MCP overlay for
3655            // that transport yet.
3656            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3657            {
3658                return Err(failure(
3659                    NodeFailureCode::BindingMismatch,
3660                    "harness MCP reservation does not match the exact PTY/ACP provider",
3661                ));
3662            }
3663            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3664            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3665                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3666            })?;
3667            // Every name below is the caller's, carried in the reservation's
3668            // launch description: which environment variables receive the
3669            // endpoint, token and helper program, what the MCP server is called,
3670            // which argv it gets and which stale variables are erased.
3671            let launch = prepared.launch();
3672            let mut env_entries = vec![
3673                (
3674                    OsString::from(launch.endpoint_env.as_str()),
3675                    prepared.endpoint().as_os_str().to_os_string(),
3676                ),
3677                (
3678                    OsString::from(launch.token_env.as_str()),
3679                    prepared.token().expose().into(),
3680                ),
3681                (
3682                    OsString::from(launch.program_env.as_str()),
3683                    prepared.helper_program().as_os_str().to_os_string(),
3684                ),
3685            ];
3686            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3687                env_entries.push(trace_entry);
3688            }
3689            let overlay = NativeMcpServerLaunchOverlay::new(
3690                provider.clone(),
3691                launch.server_name.as_str(),
3692                prepared.helper_program().as_os_str().to_os_string(),
3693                launch.args.iter().map(OsString::from).collect(),
3694                env_entries,
3695                launch.scrub_env.iter().map(OsString::from).collect(),
3696            ).map_err(|_| failure(
3697                NodeFailureCode::HarnessMcpUnavailable,
3698                "harness MCP launch overlay is unavailable",
3699            ))?;
3700            control
3701                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3702                .map_err(|_| failure(
3703                    NodeFailureCode::HarnessMcpUnavailable,
3704                    "harness MCP launch overlay could not be installed",
3705                ))?;
3706            // State-change line: the overlay is installed and will be read
3707            // back by the PTY child's environment or, for ACP,
3708            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3709            // time -- no secrets here, only which instance/provider/mode
3710            // now carries a harness-MCP door.
3711            tracing::info!(
3712                node_id = %self.node_id,
3713                instance_id = ?instance_id,
3714                provider = %provider,
3715                mode = ?mode,
3716                "harness MCP overlay installed for spawn",
3717            );
3718            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3719        } else {
3720            None
3721        };
3722        let prepared_materialization = self.prepare_session_materialization(
3723            &address,
3724            &provider,
3725            mode,
3726            environment_profile,
3727            bundle,
3728            context,
3729            managed_authority.cloned(),
3730        )?;
3731        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3732            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3733            None => (None, None),
3734        };
3735        let materialization_id = materialization_guard
3736            .as_ref()
3737            .and_then(SessionMaterializationGuard::id)
3738            .cloned();
3739        let mut environment_selection = SpawnPreparer::select_environment(self, 
3740            instance_id,
3741            &provider,
3742            mode,
3743            environment_profile,
3744        )?;
3745        // Station network overlays: Codex `-c network_access` and/or Claude
3746        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3747        let provider_home = materialization_guard
3748            .as_ref()
3749            .and_then(SessionMaterializationGuard::provider_home);
3750        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3751            &provider,
3752            mode,
3753            approval_level,
3754            network_allowlist,
3755            provider_home,
3756        )?;
3757        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3758            (prepared, args) if args.is_empty() => prepared,
3759            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3760                overlay.append_extra_args(args).map_err(|_| {
3761                    failure(
3762                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3763                        "network allowlist overlay could not merge with instance launch args",
3764                    )
3765                })?;
3766                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3767            }
3768            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3769                Some(PreparedNativeLaunchOverlay::Instance(
3770                    overlay
3771                        .into_instance_with_extra_args(args)
3772                        .map_err(|_| {
3773                            failure(
3774                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3775                                "network allowlist overlay could not merge with environment overlay",
3776                            )
3777                        })?,
3778                ))
3779            }
3780            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3781                NativeInstanceLaunchOverlay::new(
3782                    provider.clone(),
3783                    match mode {
3784                        SessionMode::Pty => TransportKind::Pty,
3785                        SessionMode::Acp => TransportKind::Acp,
3786                        SessionMode::Inline => TransportKind::Pipe,
3787                    },
3788                    Vec::new(),
3789                    args,
3790                )
3791                .map_err(|_| {
3792                    failure(
3793                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3794                        "network allowlist overlay is invalid for this transport",
3795                    )
3796                })?,
3797            )),
3798        };
3799        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3800        // overlay or appended to the overlay the spawn already carries.
3801        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3802            (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3803                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3804                match accounts.provision(m4a_provider, &session_key) {
3805                    Ok(account) => {
3806                        let identity = account.env_mutations();
3807                        let merged = match environment_overlay {
3808                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3809                                .map(PreparedNativeLaunchOverlay::Instance),
3810                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3811                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3812                            }
3813                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3814                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3815                            }
3816                        }
3817                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3818                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3819                        self.m4a_live
3820                            .lock()
3821                            .unwrap_or_else(|p| p.into_inner())
3822                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3823                        Some(merged)
3824                    }
3825                    Err(error) => {
3826                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3827                        environment_overlay
3828                    }
3829                }
3830            }
3831            (SessionMode::Pty, Some(_), None) => {
3832                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3833                environment_overlay
3834            }
3835            _ => environment_overlay,
3836        };
3837        let mut instance_overlay = environment_overlay
3838            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3839            .transpose()?
3840            .flatten();
3841        Ok(PreparedSpawn {
3842            instance_overlay,
3843            environment_selection,
3844            materialization_guard,
3845            harness_mcp_overlay,
3846            #[cfg(feature = "dig2-station-probe")]
3847            browser_station_lease_guard,
3848            materialization_id,
3849        })
3850    }
3851}
3852
3853struct SessionRecords {
3854    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3855}
3856
3857struct SpawnIdempotencyCache {
3858    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3859}
3860
3861struct SpawnIdempotencyEntry {
3862    value: SpawnIdempotencyValue,
3863    expires_at: Instant,
3864}
3865
3866enum SpawnIdempotencyValue {
3867    Standard {
3868        spec: SpawnSpec,
3869        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3870    },
3871    Managed {
3872        request: ManagedWorktreeSpawnRequest,
3873        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3874    },
3875    HarnessMcp,
3876}
3877
3878enum DurableManagedSpawnReplayDecision {
3879    Reserved,
3880    PendingLinked,
3881    Committed(ManagedWorktreeSpawnReceipt),
3882}
3883
3884impl SpawnIdempotencyValue {
3885    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3886        match self {
3887            Self::Standard { result, .. } => result
3888                .as_ref()
3889                .ok()
3890                .and_then(|receipt| receipt.context.as_ref()),
3891            Self::Managed { result, .. } => result
3892                .as_ref()
3893                .ok()
3894                .and_then(|receipt| receipt.spawn.context.as_ref()),
3895            Self::HarnessMcp => None,
3896        }
3897        .is_some_and(|receipt| &receipt.id == context_id)
3898    }
3899}
3900
3901#[derive(Clone, Copy)]
3902struct ControllerLease {
3903    connection_id: u64,
3904    expires_at: Instant,
3905}
3906
3907struct NodeEventHistory {
3908    last_sequence: u64,
3909    replay_floor_sequence: u64,
3910    events: VecDeque<NodeEventEnvelope>,
3911    record_providers: BTreeMap<SessionRecordId, AgentId>,
3912    removed_record_providers: BTreeMap<u64, AgentId>,
3913}
3914
3915impl NodeEventHistory {
3916    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3917        Self {
3918            last_sequence: 0,
3919            replay_floor_sequence: 1,
3920            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3921            record_providers,
3922            removed_record_providers: BTreeMap::new(),
3923        }
3924    }
3925}
3926
3927impl NodeShared {
3928    fn new_with_incarnation(
3929        handle: Gate4AgentHandle,
3930        access_token: String,
3931        node_id: NodeId,
3932        incarnation_id: NodeIncarnationId,
3933        workspaces: Vec<WorkspaceConfig>,
3934        enabled_providers: Vec<AgentId>,
3935        provider_runtime_statuses: ProviderRuntimeStatuses,
3936        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3937        provider_contracts: Vec<ProviderContractSupport>,
3938        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3939        spawn_profiles: SpawnProfileRegistry,
3940        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3941        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3942        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3943        state_path: Option<PathBuf>,
3944        records: Vec<ManagedSessionRecord>,
3945        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3946        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3947        materializations: Vec<MaterializationOwnershipRecord>,
3948        persistence_warning: Option<String>,
3949        input_settle_timeout_ms: u64,
3950    ) -> Self {
3951        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3952        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3953        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3954        let (harness_mcp_proxy_event_tx, _) =
3955            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3956        let record_providers = records
3957            .iter()
3958            .map(|record| (record.record_id.clone(), record.provider.clone()))
3959            .collect();
3960        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3961            .iter()
3962            .map(|workspace| {
3963                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3964            })
3965            .collect();
3966        for lease in &managed_worktrees {
3967            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3968        }
3969        let managed_worktree_profiles = workspaces.iter()
3970            .map(|workspace| (
3971                workspace.workspace_id.clone(),
3972                workspace.managed_worktree_profiles.clone(),
3973            ))
3974            .collect();
3975        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3976            managed_worktrees,
3977            managed_worktree_tombstones,
3978        ).expect("durable managed worktree registry was validated while loading");
3979        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3980        managed_worktrees.reattach_record_holders(
3981            &managed_worktree_record_holders(&records),
3982            unix_time_ms(),
3983        );
3984        Self {
3985            handle,
3986            access_token,
3987            node_id,
3988            incarnation_id,
3989            started_at_unix_ms: std::time::SystemTime::now()
3990                .duration_since(std::time::UNIX_EPOCH)
3991                .unwrap_or_default()
3992                .as_millis()
3993                .min(u64::MAX as u128) as u64,
3994            workspaces: RwLock::new(
3995                workspaces
3996                    .into_iter()
3997                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3998                    .collect(),
3999            ),
4000            worktree_service_modes,
4001            managed_worktree_profiles,
4002            managed_worktrees: Mutex::new(managed_worktrees),
4003            enabled_providers,
4004            provider_runtime_statuses,
4005            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4006            provider_runtime_monitor,
4007            provider_contracts,
4008            provider_adapter_contracts,
4009            spawn_profiles,
4010            environment_profiles: RwLock::new(BTreeMap::new()),
4011            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4012            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4013            #[cfg(feature = "dig2-station-probe")]
4014            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4015            bundle_catalog: RwLock::new(BundleCatalog::default()),
4016            delivery_store: Mutex::new(None),
4017            context_pack_store: Mutex::new(None),
4018            context_catalog: RwLock::new(ContextPackCatalog::default()),
4019            native_launch_profile_control,
4020            harness_mcp_registry: None,
4021            native_session_catalog,
4022            session_environment_materializer,
4023            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4024            m4a_live: Mutex::new(BTreeMap::new()),
4025            materializations: Mutex::new(
4026                materializations
4027                    .into_iter()
4028                    .map(|record| (record.id().clone(), record))
4029                    .collect(),
4030            ),
4031            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4032                entries: BTreeMap::new(),
4033            }),
4034            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4035            controller: Mutex::new(None),
4036            history: Mutex::new(NodeEventHistory::new(record_providers)),
4037            event_tx,
4038            terminal_event_tx,
4039            agent_stream_event_tx,
4040            harness_mcp_proxy_event_tx,
4041            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4042            pty_input_wake: tokio::sync::Notify::new(),
4043            pty_input_marks: Mutex::new(BTreeMap::new()),
4044            next_connection_id: AtomicU64::new(1),
4045            next_instance_id: AtomicU64::new(1),
4046            next_command_id: AtomicU64::new(1),
4047            shutdown: AtomicBool::new(false),
4048            shutdown_notify: Notify::new(),
4049            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4050            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4051            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4052            mutation_gate: AsyncMutex::new(()),
4053            session_bindings: Mutex::new(BTreeMap::new()),
4054            session_records: Mutex::new(SessionRecords {
4055                records: records
4056                    .into_iter()
4057                    .map(|record| (record.record_id.clone(), record))
4058                    .collect(),
4059            }),
4060            state_path,
4061            persistence_error: RwLock::new(
4062                persistence_warning
4063                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4064            ),
4065            state_transaction: Mutex::new(()),
4066            input_settle_timeout_ms,
4067            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4068            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4069            shell_efficiency_profile: OnceLock::new(),
4070            native_session_gauge: AtomicUsize::new(0),
4071            connection_loop_iterations: AtomicU64::new(0),
4072            connection_events_sent: AtomicU64::new(0),
4073            rejected_commands_total: AtomicU64::new(0),
4074            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4075            drive_loop_iterations_total: AtomicU64::new(0),
4076            drive_loop_iterations_idle: AtomicU64::new(0),
4077            session_record_retention: SessionRecordRetentionConfig::default(),
4078            retired_records_total: AtomicU64::new(0),
4079            #[cfg(feature = "fixture")]
4080            fixture_semantic_hook_policy: false,
4081            #[cfg(feature = "fixture")]
4082            fixture_spawn_managed_worktree_v2_failure_probe:
4083                SpawnManagedWorktreeV2FailureProbe::default(),
4084        }
4085    }
4086
4087    #[cfg(test)]
4088    fn new(
4089        handle: Gate4AgentHandle,
4090        access_token: String,
4091        node_id: NodeId,
4092        workspaces: Vec<WorkspaceConfig>,
4093        enabled_providers: Vec<AgentId>,
4094    ) -> Self {
4095        Self::new_with_incarnation(
4096            handle,
4097            access_token,
4098            node_id,
4099            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4100            workspaces,
4101            enabled_providers,
4102            ProviderRuntimeStatuses::default(),
4103            None,
4104            Vec::new(),
4105            Vec::new(),
4106            SpawnProfileRegistry::default(),
4107            None,
4108            None,
4109            None,
4110            None,
4111            Vec::new(),
4112            Vec::new(),
4113            Vec::new(),
4114            Vec::new(),
4115            None,
4116            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4117        )
4118    }
4119
4120    fn persistence_error(&self) -> Option<String> {
4121        self.persistence_error
4122            .read()
4123            .unwrap_or_else(|poisoned| poisoned.into_inner())
4124            .clone()
4125    }
4126
4127    async fn admit_provider_runtime(
4128        &self,
4129        provider: &AgentId,
4130        requirement: ProviderRuntimeRequirement,
4131    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4132        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4133            let pipe_transport = monitor.supports_pipe_transport(provider);
4134            let acp_transport = monitor.supports_acp_transport(provider);
4135            let provider = provider.clone();
4136            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4137            let (status, admission) = timeout(
4138                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4139                refresh,
4140            )
4141            .await
4142            .map_err(|_| failure(
4143                NodeFailureCode::BackendBusy,
4144                "provider runtime probe exceeded its bounded deadline",
4145            ))?
4146            .map_err(|_| failure(
4147                NodeFailureCode::BackendOperationFailed,
4148                "provider runtime probe task failed",
4149            ))?;
4150            if let Some(status) = status {
4151                self
4152                    .provider_runtime_status_updates
4153                    .write()
4154                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4155                    .insert(status.provider().clone(), status);
4156            }
4157            admission.and_then(|policy| {
4158                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4159            })
4160        } else {
4161            // No live monitor means no catalog to consult -- there is
4162            // nothing here to confirm this provider declares a Pipe or ACP
4163            // transport, so Inline/Acp are rejected the same conservative
4164            // way an undeclared provider always was, rather than admitted
4165            // on trust.
4166            crate::provider_runtime::admit_status(
4167                &self.provider_runtime_statuses,
4168                provider,
4169                false,
4170                false,
4171                requirement,
4172            )
4173        };
4174        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4175        admission.map_err(|error| match error {
4176            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4177                NodeFailureCode::BackendOperationFailed,
4178                "provider launcher is unavailable",
4179            ),
4180            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4181                NodeFailureCode::UnsupportedCapability,
4182                "provider semantic capability is not verified",
4183            ),
4184            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4185                NodeFailureCode::BackendBusy,
4186                "provider runtime probe is already in progress",
4187            ),
4188        })
4189    }
4190
4191    /// Whether the catalog declares a Pipe transport for `provider` --
4192    /// the fact `require_policy` needs to admit or reject
4193    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4194    /// catalog to consult, so this stays conservative and rejects, matching
4195    /// `admit_provider_runtime`'s own no-monitor branch.
4196    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4197        match self.provider_runtime_monitor.as_deref() {
4198            Some(monitor) => monitor.supports_pipe_transport(provider),
4199            None => false,
4200        }
4201    }
4202
4203    /// Whether the catalog declares an ACP transport for `provider` -- the
4204    /// fact `require_policy` needs to admit or reject
4205    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4206    /// `provider_supports_pipe_transport` exactly, including the
4207    /// conservative no-monitor default.
4208    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4209        match self.provider_runtime_monitor.as_deref() {
4210            Some(monitor) => monitor.supports_acp_transport(provider),
4211            None => false,
4212        }
4213    }
4214
4215    /// Grants hook ingestion to a provider whose catalog entry declares a
4216    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4217    ///
4218    /// `admit_status`'s `RawPassthrough` arm hands back
4219    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4220    /// correct for everything it was written to answer, and wrong for hooks
4221    /// specifically. A hook event is not inferred from terminal behaviour,
4222    /// so it has nothing to do with whether a launcher probe verified this
4223    /// CLI version's PTY contract; the authority is the catalog declaring
4224    /// the adapter plus the ingress authenticating the route. Without this
4225    /// the node installs a provider's hooks, the CLI faithfully calls them,
4226    /// and every event is refused -- which is exactly the state grok was in.
4227    ///
4228    /// Applied to both admission paths so the monitor's own derivation and
4229    /// this coarse fallback cannot disagree about the same provider.
4230    fn admit_declared_hook_semantics(
4231        &self,
4232        provider: &AgentId,
4233        policy: ProviderRuntimePolicy,
4234    ) -> ProviderRuntimePolicy {
4235        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4236            return policy;
4237        }
4238        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4239            contract.provider == *provider && contract.family == AdapterFamily::Hook
4240        });
4241        if !declares_hook {
4242            return policy;
4243        }
4244        ProviderRuntimePolicy::new(
4245            policy.raw_pty_lifecycle,
4246            policy.semantic_readiness,
4247            policy.structured_prompt,
4248            policy.provider_session_identity,
4249            policy.semantic_resume,
4250            true,
4251        )
4252        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4253    }
4254
4255    /// Station network allowlist → provider launch overlay argv.
4256    ///
4257    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4258    ///   `provider_native` under Moderate (PTY or ACP).
4259    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4260    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4261    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4262    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4263    ///   no overlay.
4264    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4265    ///   empty overlay when no `provider_native`; invented native keys already
4266    ///   refuse at load / resolve.
4267    ///
4268    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4269    fn resolve_network_allowlist_overlay_args(
4270        &self,
4271        provider: &AgentId,
4272        mode: SessionMode,
4273        approval_level: ApprovalLevel,
4274        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4275        provider_home: Option<&Path>,
4276    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4277        let Some(allowlist_id) = network_allowlist else {
4278            return Ok(Vec::new());
4279        };
4280        let catalog = self
4281            .network_allowlist_catalog
4282            .read()
4283            .unwrap_or_else(|poisoned| poisoned.into_inner());
4284        let Some(entry) = catalog.get(allowlist_id) else {
4285            return Err(failure(
4286                NodeFailureCode::UnknownNetworkAllowlist,
4287                "spawn network allowlist is unavailable on this node",
4288            ));
4289        };
4290
4291        // Claude: settings-shaped overlay from permits (not provider_native).
4292        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4293            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4294                |message| {
4295                    failure(
4296                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4297                        &message,
4298                    )
4299                },
4300            )?;
4301            if !matches!(mode, SessionMode::Pty) {
4302                return Err(failure(
4303                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4304                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4305                ));
4306            }
4307            let Some(home) = provider_home else {
4308                return Err(failure(
4309                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4310                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4311                ));
4312            };
4313            let domains =
4314                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4315            if domains.is_empty() {
4316                return Err(failure(
4317                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4318                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4319                ));
4320            }
4321            let settings_path = home.join(
4322                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4323            );
4324            let json =
4325                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4326            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4327                |_| {
4328                    failure(
4329                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4330                        "Claude station network settings sidecar could not be written under ProviderHome",
4331                    )
4332                },
4333            )?;
4334            let overlay =
4335                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4336            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4337        }
4338
4339        let Some(native) = entry.provider_native.as_ref() else {
4340            return Ok(Vec::new());
4341        };
4342        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4343            provider.as_str(),
4344            approval_level,
4345            native,
4346        )
4347        .map_err(|message| {
4348            failure(
4349                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4350                &message,
4351            )
4352        })?;
4353        if overlay.is_empty() {
4354            return Ok(Vec::new());
4355        }
4356        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4357            return Err(failure(
4358                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4359                "Codex network_access -c overlay requires PTY or ACP transport",
4360            ));
4361        }
4362        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4363    }
4364
4365    /// Backward-compatible alias used by Codex-focused unit tests.
4366    fn resolve_codex_network_access_overlay_args(
4367        &self,
4368        provider: &AgentId,
4369        mode: SessionMode,
4370        approval_level: ApprovalLevel,
4371        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4372    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4373        self.resolve_network_allowlist_overlay_args(
4374            provider,
4375            mode,
4376            approval_level,
4377            network_allowlist,
4378            None,
4379        )
4380    }
4381
4382    fn resolve_environment_profile(
4383        &self,
4384        resolved: &ResolvedSpawnSpec,
4385    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4386        // Station network allowlist catalog enforce (plan
4387        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4388        // catalog — unknown ids refuse rather than silent ambient. Runs even
4389        // when no environment_profile_id is present so a bare allowlist still
4390        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4391        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4392            let catalog = self
4393                .network_allowlist_catalog
4394                .read()
4395                .unwrap_or_else(|poisoned| poisoned.into_inner());
4396            let Some(entry) = catalog.get(allowlist_id) else {
4397                return Err(failure(
4398                    NodeFailureCode::UnknownNetworkAllowlist,
4399                    "spawn network allowlist is unavailable on this node",
4400                ));
4401            };
4402            // Track B: refuse provider-native mappings this provider cannot
4403            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4404            if let Some(native) = entry.provider_native.as_ref() {
4405                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4406                    resolved.provider.as_str(),
4407                    resolved.approval_level,
4408                    native,
4409                ) {
4410                    return Err(failure(
4411                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4412                        &message,
4413                    ));
4414                }
4415            }
4416        }
4417        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4418        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4419        // cheap named-pipe path reachability must succeed (Windows
4420        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4421        // NodeFailureCode. Runs even without environment_profile_id (same
4422        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4423        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4424        // acquired later in spawn_session_with_deadline (after instance id).
4425        #[cfg(feature = "dig2-station-probe")]
4426        if resolved.browser_profile_id.is_some() {
4427            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4428            match probe_station_reachable() {
4429                Ok(()) => {}
4430                Err(StationProbeError::UnsupportedPlatform)
4431                | Err(StationProbeError::InvalidPipeSuffix) => {
4432                    return Err(failure(
4433                        NodeFailureCode::BrowserStationProbeUnavailable,
4434                        "dig2browser station probe unavailable on this platform",
4435                    ));
4436                }
4437                Err(StationProbeError::Unreachable) => {
4438                    return Err(failure(
4439                        NodeFailureCode::BrowserStationUnreachable,
4440                        "dig2browser station named-pipe is unreachable",
4441                    ));
4442                }
4443            }
4444        }
4445        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4446            return Ok(None);
4447        };
4448        let profiles = self
4449            .environment_profiles
4450            .read()
4451            .unwrap_or_else(|poisoned| poisoned.into_inner());
4452        let binding = profiles.get(profile_id).ok_or_else(|| {
4453            failure(
4454                NodeFailureCode::UnknownEnvironmentProfile,
4455                "spawn environment profile is unavailable on this node",
4456            )
4457        })?;
4458        if binding.provider != resolved.provider
4459            || binding.native_profile_id(resolved.mode).is_none()
4460        {
4461            return Err(failure(
4462                NodeFailureCode::EnvironmentProfileBindingMismatch,
4463                "spawn environment profile does not match the provider and mode",
4464            ));
4465        }
4466        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4467        // after catalog + optional dig2 probe above. Empty/whitespace ids
4468        // already refuse at Spawn*Id construction.
4469        Ok(Some(ResolvedEnvironmentProfileReceipt {
4470            profile_id: binding.id.clone(),
4471            profile_revision: binding.revision.clone(),
4472            network_allowlist: resolved.network_allowlist.clone(),
4473            browser_profile_id: resolved.browser_profile_id.clone(),
4474        }))
4475    }
4476
4477    fn resolve_bundle(
4478        &self,
4479        resolved: &ResolvedSpawnSpec,
4480        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4481    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4482        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4483            return Ok(None);
4484        };
4485        let catalog = self.bundle_catalog
4486            .read()
4487            .unwrap_or_else(|poisoned| poisoned.into_inner());
4488        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4489                failure(
4490                    NodeFailureCode::UnknownBundle,
4491                    "spawn bundle is unavailable on this node",
4492                )
4493            })?;
4494        self.bundle_layout(
4495            &resolved.provider,
4496            resolved.mode,
4497            environment_profile,
4498            bundle,
4499        )?;
4500        Ok(Some(bundle.receipt()))
4501    }
4502
4503    fn begin_delivery_stage(
4504        &self,
4505        manifest: DeliveryBundleManifestV2,
4506    ) -> Result<NodeResponse, NodeFailure> {
4507        let mut store = self
4508            .delivery_store
4509            .lock()
4510            .unwrap_or_else(|poisoned| poisoned.into_inner());
4511        let store = store.as_mut().ok_or_else(|| {
4512            failure(
4513                NodeFailureCode::DeliveryStageStorageFailed,
4514                "node delivery store is unavailable",
4515            )
4516        })?;
4517        let begun = store.begin(manifest).map_err(delivery_failure)?;
4518        Ok(NodeResponse::DeliveryStageBegun {
4519            stage_id: begun.stage_id,
4520            manifest_digest: begun.manifest_digest,
4521            missing_blobs: begun.missing_blobs,
4522        })
4523    }
4524
4525    fn put_delivery_blob_chunk(
4526        &self,
4527        stage_id: DeliveryStageId,
4528        blob_digest: DeliveryBlobDigestV1,
4529        offset: u64,
4530        chunk_hex: DeliveryBlobChunkHexV1,
4531    ) -> Result<NodeResponse, NodeFailure> {
4532        let mut store = self
4533            .delivery_store
4534            .lock()
4535            .unwrap_or_else(|poisoned| poisoned.into_inner());
4536        let store = store.as_mut().ok_or_else(|| {
4537            failure(
4538                NodeFailureCode::DeliveryStageStorageFailed,
4539                "node delivery store is unavailable",
4540            )
4541        })?;
4542        let accepted = store
4543            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4544            .map_err(delivery_failure)?;
4545        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4546            stage_id: accepted.stage_id,
4547            blob_digest: accepted.blob_digest,
4548            next_offset: accepted.next_offset,
4549        })
4550    }
4551
4552    fn commit_delivery_stage(
4553        &self,
4554        stage_id: DeliveryStageId,
4555    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4556        let mut store = self
4557            .delivery_store
4558            .lock()
4559            .unwrap_or_else(|poisoned| poisoned.into_inner());
4560        let store = store.as_mut().ok_or_else(|| {
4561            failure(
4562                NodeFailureCode::DeliveryStageStorageFailed,
4563                "node delivery store is unavailable",
4564            )
4565        })?;
4566        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4567        let mut catalog = self
4568            .bundle_catalog
4569            .write()
4570            .unwrap_or_else(|poisoned| poisoned.into_inner());
4571        let mut next_catalog = catalog.clone();
4572        next_catalog
4573            .insert_idempotent(prepared.bundle().clone())
4574            .map_err(|_| {
4575                failure(
4576                    NodeFailureCode::DeliveryStageConflict,
4577                    "delivery bundle identity conflicts with installed content",
4578                )
4579            })?;
4580        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4581        *catalog = next_catalog;
4582        Ok(receipt)
4583    }
4584
4585    fn abort_delivery_stage(
4586        &self,
4587        stage_id: &DeliveryStageId,
4588    ) -> Result<(), NodeFailure> {
4589        let mut store = self
4590            .delivery_store
4591            .lock()
4592            .unwrap_or_else(|poisoned| poisoned.into_inner());
4593        let store = store.as_mut().ok_or_else(|| {
4594            failure(
4595                NodeFailureCode::DeliveryStageStorageFailed,
4596                "node delivery store is unavailable",
4597            )
4598        })?;
4599        store.abort(stage_id).map_err(delivery_failure)
4600    }
4601
4602    fn resolve_context(
4603        &self,
4604        resolved: &ResolvedSpawnSpec,
4605    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4606        let Some(context_id) = resolved.context_id.as_ref() else {
4607            return Ok(None);
4608        };
4609        self.context_catalog
4610            .read()
4611            .unwrap_or_else(|poisoned| poisoned.into_inner())
4612            .get(context_id)
4613            .map(|context| context.receipt().clone())
4614            .ok_or_else(|| {
4615                failure(
4616                    NodeFailureCode::UnknownContextPack,
4617                    "spawn context pack is unavailable on this node",
4618                )
4619            })
4620            .map(Some)
4621    }
4622
4623    fn bundle_layout(
4624        &self,
4625        provider: &AgentId,
4626        mode: SessionMode,
4627        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4628        bundle: &NodeBundle,
4629    ) -> Result<BundleProviderLayout, NodeFailure> {
4630        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4631            failure(
4632                NodeFailureCode::BundleBindingMismatch,
4633                "spawn bundle does not match the provider and mode",
4634            )
4635        })?;
4636        if layout != BundleProviderLayout::Codex {
4637            return Ok(layout);
4638        }
4639        let profile = environment_profile.and_then(|receipt| {
4640            let profiles = self
4641                .environment_profiles
4642                .read()
4643                .unwrap_or_else(|poisoned| poisoned.into_inner());
4644            let binding = profiles.get(&receipt.profile_id)?;
4645            if binding.revision != receipt.profile_revision
4646                || &binding.provider != provider
4647                || binding.native_profile_id(mode).is_none()
4648            {
4649                return None;
4650            }
4651            self.environment_materialization_profiles
4652                .read()
4653                .unwrap_or_else(|poisoned| poisoned.into_inner())
4654                .get(&receipt.profile_id)
4655                .cloned()
4656        });
4657        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4658            return Err(failure(
4659                NodeFailureCode::BundleBindingMismatch,
4660                "Codex bundle requires an exact isolated CODEX_HOME profile",
4661            ));
4662        }
4663        Ok(layout)
4664    }
4665
4666    fn select_environment_profile(
4667        &self,
4668        instance_id: AgentInstanceId,
4669        provider: &AgentId,
4670        mode: SessionMode,
4671        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4672    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4673        let Some(environment_profile) = environment_profile else {
4674            return Ok(None);
4675        };
4676        let native_profile_id = {
4677            let profiles = self
4678                .environment_profiles
4679                .read()
4680                .unwrap_or_else(|poisoned| poisoned.into_inner());
4681            let binding = profiles
4682                .get(&environment_profile.profile_id)
4683                .ok_or_else(|| {
4684                    failure(
4685                        NodeFailureCode::UnknownEnvironmentProfile,
4686                        "session environment profile is unavailable on this node",
4687                    )
4688                })?;
4689            if &binding.revision != &environment_profile.profile_revision
4690                || &binding.provider != provider
4691            {
4692                return Err(failure(
4693                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4694                    "session environment profile revision or provider changed",
4695                ));
4696            }
4697            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4698                failure(
4699                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4700                    "session environment profile does not support this mode",
4701                )
4702            })?
4703        };
4704        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4705            failure(
4706                NodeFailureCode::BackendOperationFailed,
4707                "native environment profile controller is unavailable",
4708            )
4709        })?;
4710        control
4711            .select_native_launch_profile(instance_id, native_profile_id)
4712            .map_err(|_| {
4713                failure(
4714                    NodeFailureCode::BackendOperationFailed,
4715                    "native environment profile selection failed",
4716                )
4717            })?;
4718        Ok(Some(NativeEnvironmentSelectionGuard {
4719            control: Some(control),
4720            instance_id,
4721        }))
4722    }
4723
4724    fn install_prepared_launch_overlay(
4725        &self,
4726        instance_id: AgentInstanceId,
4727        overlay: PreparedNativeLaunchOverlay,
4728    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4729        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4730            failure(
4731                NodeFailureCode::BackendOperationFailed,
4732                "native launch profile controller is unavailable",
4733            )
4734        })?;
4735        match overlay {
4736            PreparedNativeLaunchOverlay::Environment(overlay) => {
4737                control
4738                    .install_native_launch_environment_overlay(instance_id, overlay)
4739                    .map_err(|_| failure(
4740                        NodeFailureCode::BackendOperationFailed,
4741                        "session environment overlay installation failed",
4742                    ))?;
4743                Ok(None)
4744            }
4745            PreparedNativeLaunchOverlay::Instance(overlay) => {
4746                control
4747                    .install_native_instance_launch_overlay(instance_id, overlay)
4748                    .map_err(|_| failure(
4749                        NodeFailureCode::BundleBindingMismatch,
4750                        "session bundle launch overlay installation failed",
4751                    ))?;
4752                Ok(Some(NativeInstanceOverlayGuard {
4753                    control: Some(control),
4754                    instance_id,
4755                }))
4756            }
4757        }
4758    }
4759
4760    fn environment_materialization_profile(
4761        &self,
4762        environment_profile: &ResolvedEnvironmentProfileReceipt,
4763    ) -> Option<NodeSessionMaterializationProfile> {
4764        self.environment_materialization_profiles
4765            .read()
4766            .unwrap_or_else(|poisoned| poisoned.into_inner())
4767            .get(&environment_profile.profile_id)
4768            .cloned()
4769    }
4770
4771    fn materialization_profile(
4772        &self,
4773        provider: &AgentId,
4774        mode: SessionMode,
4775        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4776        bundle: Option<&ResolvedBundleReceipt>,
4777        context: Option<&ResolvedContextPackReceipt>,
4778    ) -> Result<Option<(
4779        NodeSessionMaterializationProfile,
4780        bool,
4781        Option<BundleProviderLayout>,
4782    )>, NodeFailure> {
4783        let environment = environment_profile
4784            .and_then(|receipt| self.environment_materialization_profile(receipt));
4785        let (bundle, bundle_layout) = match bundle {
4786            Some(receipt) => {
4787                let catalog = self.bundle_catalog.read()
4788                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4789                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4790                    NodeFailureCode::UnknownBundle,
4791                    "session bundle is unavailable on this node",
4792                ))?;
4793                if installed.receipt() != *receipt {
4794                    return Err(failure(
4795                        NodeFailureCode::BundleBindingMismatch,
4796                        "session bundle revision or digest changed",
4797                    ));
4798                }
4799                let layout = self.bundle_layout(
4800                    provider,
4801                    mode,
4802                    environment_profile,
4803                    installed,
4804                )?;
4805                (Some(installed.clone()), Some(layout))
4806            }
4807            None => (None, None),
4808        };
4809        let has_environment_overlay = environment.is_some() || context.is_some();
4810        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4811            (None, None, None) => None,
4812            (Some(profile), None, None) => Some(Ok(profile)),
4813            (None, Some(bundle), Some(layout)) => {
4814                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4815            }
4816            (Some(profile), Some(bundle), Some(layout)) => {
4817                Some(profile.with_bundle(bundle, layout))
4818            }
4819            _ => {
4820                return Err(failure(
4821                    NodeFailureCode::BundleBindingMismatch,
4822                    "session bundle provider layout is unavailable",
4823                ));
4824            }
4825        };
4826        let profile = profile
4827            .transpose()
4828            .map_err(|_| failure(
4829                NodeFailureCode::BundleMaterializationFailed,
4830                "session bundle materialization profile is invalid",
4831            ))?;
4832        let profile = match (profile, context) {
4833            (Some(profile), Some(receipt)) => {
4834                let catalog = self.context_catalog
4835                    .read()
4836                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4837                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4838                    NodeFailureCode::UnknownContextPack,
4839                    "session context pack is unavailable on this node",
4840                ))?;
4841                if pack.receipt() != receipt {
4842                    return Err(failure(
4843                        NodeFailureCode::ContextPackMaterializationFailed,
4844                        "session context pack identity changed",
4845                    ));
4846                }
4847                profile.with_context(pack)
4848            }
4849            (None, Some(receipt)) => {
4850                let catalog = self.context_catalog
4851                    .read()
4852                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4853                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4854                    NodeFailureCode::UnknownContextPack,
4855                    "session context pack is unavailable on this node",
4856                ))?;
4857                if pack.receipt() != receipt {
4858                    return Err(failure(
4859                        NodeFailureCode::ContextPackMaterializationFailed,
4860                        "session context pack identity changed",
4861                    ));
4862                }
4863                NodeSessionMaterializationProfile::from_context(pack)
4864            }
4865            (Some(profile), None) => Ok(profile),
4866            (None, None) => return Ok(None),
4867        }
4868        .map_err(|_| failure(
4869            NodeFailureCode::ContextPackMaterializationFailed,
4870            "session context pack materialization profile is invalid",
4871        ))?;
4872        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4873    }
4874
4875    fn prepare_session_materialization<'a>(
4876        &'a self,
4877        address: &SessionAddress,
4878        provider: &AgentId,
4879        mode: SessionMode,
4880        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4881        bundle: Option<&ResolvedBundleReceipt>,
4882        context: Option<&ResolvedContextPackReceipt>,
4883        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4884    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4885        let Some((profile, has_environment_overlay, bundle_layout)) =
4886            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4887        else {
4888            return Ok(None);
4889        };
4890        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4891            failure(
4892                NodeFailureCode::BackendOperationFailed,
4893                "session environment materializer is unavailable",
4894            )
4895        })?;
4896        let materialization_failure_code = if context.is_some() {
4897            NodeFailureCode::ContextPackMaterializationFailed
4898        } else if bundle.is_some() {
4899            NodeFailureCode::BundleMaterializationFailed
4900        } else {
4901            NodeFailureCode::BackendOperationFailed
4902        };
4903        let id = MaterializationId::new(format!(
4904            "mat-{}-{}-{}",
4905            self.incarnation_id,
4906            address.session.instance_id.0,
4907            address.session.generation.0,
4908        ))
4909        .map_err(|_| {
4910            failure(
4911                NodeFailureCode::BackendOperationFailed,
4912                "session environment materialization identity failed",
4913            )
4914        })?;
4915        let owner = MaterializationOwner::Session {
4916            incarnation_id: self.incarnation_id,
4917            instance_id: address.session.instance_id,
4918            generation: address.session.generation,
4919        };
4920        let mut ownership = materializer
4921            .begin(
4922                id.clone(),
4923                environment_profile.cloned(),
4924                bundle.cloned(),
4925                context.cloned(),
4926                owner,
4927                managed_lease_id,
4928                &profile,
4929                unix_time_ms(),
4930            )
4931            .map_err(|_| {
4932                failure(
4933                    materialization_failure_code,
4934                    "session materialization preparation failed",
4935                )
4936            })?;
4937        {
4938            let _transaction = self
4939                .state_transaction
4940                .lock()
4941                .unwrap_or_else(|poisoned| poisoned.into_inner());
4942            let duplicate = self
4943                .materializations
4944                .lock()
4945                .unwrap_or_else(|poisoned| poisoned.into_inner())
4946                .insert(id.clone(), ownership.clone());
4947            if duplicate.is_some() {
4948                return Err(failure(
4949                    NodeFailureCode::BackendOperationFailed,
4950                    "session environment materialization identity conflict",
4951                ));
4952            }
4953            if let Err(error) = self.persist_state_locked() {
4954                self.materializations
4955                    .lock()
4956                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4957                    .remove(&id);
4958                return Err(persistence_failure(error));
4959            }
4960        }
4961        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4962            Ok(environment) => environment,
4963            Err(_) => {
4964                let transaction = self
4965                    .state_transaction
4966                    .lock()
4967                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4968                self.materializations
4969                    .lock()
4970                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4971                    .insert(id.clone(), ownership.clone());
4972                if self.persist_state_locked().is_err() {
4973                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4974                }
4975                drop(transaction);
4976                if ownership.state() == MaterializationState::CleanupRequired {
4977                    let _ = self.cleanup_materialization(&id);
4978                }
4979                return Err(failure(
4980                    materialization_failure_code,
4981                    "session materialization failed",
4982                ));
4983            }
4984        };
4985        {
4986            let _transaction = self
4987                .state_transaction
4988                .lock()
4989                .unwrap_or_else(|poisoned| poisoned.into_inner());
4990            self.materializations
4991                .lock()
4992                .unwrap_or_else(|poisoned| poisoned.into_inner())
4993                .insert(id.clone(), ownership.clone());
4994            if let Err(error) = self.persist_state_locked() {
4995                drop(_transaction);
4996                let _ = self.cleanup_materialization(&id);
4997                return Err(persistence_failure(error));
4998            }
4999        }
5000        let guard = SessionMaterializationGuard {
5001            shared: self,
5002            id: Some(id),
5003            provider_home: Some(ownership.provider_home().to_path_buf()),
5004        };
5005        let bundle_arguments = match bundle {
5006            Some(receipt) => {
5007                let catalog = self.bundle_catalog.read()
5008                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5009                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5010                    NodeFailureCode::UnknownBundle,
5011                    "session bundle is unavailable on this node",
5012                ))?;
5013                if installed.receipt() != *receipt {
5014                    return Err(failure(
5015                        NodeFailureCode::BundleBindingMismatch,
5016                        "session bundle revision or digest changed",
5017                    ));
5018                }
5019                let layout = bundle_layout.ok_or_else(|| failure(
5020                    NodeFailureCode::BundleBindingMismatch,
5021                    "materialized bundle provider layout is unavailable",
5022                ))?;
5023                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5024                    failure(
5025                        NodeFailureCode::BundleMaterializationFailed,
5026                        "session bundle byte revalidation failed",
5027                    )
5028                })?;
5029                Some(bundle_launch_arguments(
5030                    layout,
5031                    ownership.bundle_root(),
5032                    ownership.provider_home(),
5033                ).map_err(|_| failure(
5034                    NodeFailureCode::BundleBindingMismatch,
5035                    "session bundle launch binding is invalid",
5036                ))?)
5037            }
5038            None => None,
5039        };
5040        if let Some(receipt) = context {
5041            materializer
5042                .revalidate_context(&ownership, receipt)
5043                .map_err(|_| failure(
5044                    NodeFailureCode::ContextPackMaterializationFailed,
5045                    "session context pack byte revalidation failed",
5046                ))?;
5047        }
5048        let transport = match mode {
5049            SessionMode::Pty => TransportKind::Pty,
5050            SessionMode::Inline => TransportKind::Pipe,
5051            SessionMode::Acp => TransportKind::Acp,
5052        };
5053        let overlay = if let Some(extra_args) = bundle_arguments {
5054            Some(PreparedNativeLaunchOverlay::Instance(
5055                NativeInstanceLaunchOverlay::new(
5056                    provider.clone(),
5057                    transport,
5058                    if has_environment_overlay {
5059                        environment
5060                    } else {
5061                        Vec::new()
5062                    },
5063                    extra_args,
5064                )
5065                .map_err(|_| {
5066                    failure(
5067                        NodeFailureCode::BundleBindingMismatch,
5068                        "session bundle launch overlay is invalid",
5069                    )
5070                })?,
5071            ))
5072        } else if has_environment_overlay {
5073            let context_only = environment_profile.is_none()
5074                && bundle.is_none()
5075                && context.is_some();
5076            let overlay = if context_only {
5077                NativeLaunchEnvironmentOverlay::new_context_root(
5078                    provider.clone(),
5079                    transport,
5080                    environment,
5081                )
5082                .map_err(|_| {
5083                    failure(
5084                        NodeFailureCode::ContextPackMaterializationFailed,
5085                        "context-only launch overlay is invalid",
5086                    )
5087                })?
5088            } else {
5089                NativeLaunchEnvironmentOverlay::new(
5090                    provider.clone(),
5091                    transport,
5092                    environment,
5093                )
5094                .map_err(|_| {
5095                    failure(
5096                        NodeFailureCode::BackendOperationFailed,
5097                        "session environment overlay is invalid",
5098                    )
5099                })?
5100            };
5101            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5102        } else {
5103            None
5104        };
5105        Ok(Some((overlay, guard)))
5106    }
5107
5108    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5109        let Some(mut ownership) = self
5110            .materializations
5111            .lock()
5112            .unwrap_or_else(|poisoned| poisoned.into_inner())
5113            .get(id)
5114            .cloned()
5115        else {
5116            return Ok(());
5117        };
5118        if ownership.state() != MaterializationState::CleanupRequired {
5119            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5120                failure(
5121                    NodeFailureCode::BackendOperationFailed,
5122                    "session environment cleanup state is invalid",
5123                )
5124            })?;
5125        }
5126        {
5127            let _transaction = self
5128                .state_transaction
5129                .lock()
5130                .unwrap_or_else(|poisoned| poisoned.into_inner());
5131            let previous = self
5132                .materializations
5133                .lock()
5134                .unwrap_or_else(|poisoned| poisoned.into_inner())
5135                .insert(id.clone(), ownership.clone());
5136            if let Err(error) = self.persist_state_locked() {
5137                let mut materializations = self
5138                    .materializations
5139                    .lock()
5140                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5141                if let Some(previous) = previous {
5142                    materializations.insert(id.clone(), previous);
5143                }
5144                return Err(persistence_failure(error));
5145            }
5146        }
5147        let cleanup = self
5148            .session_environment_materializer
5149            .as_ref()
5150            .ok_or(())
5151            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5152        if cleanup.is_err() {
5153            let _ = ownership.mark_recovery_required(unix_time_ms());
5154            self.materializations
5155                .lock()
5156                .unwrap_or_else(|poisoned| poisoned.into_inner())
5157                .insert(id.clone(), ownership);
5158            if self.persist_state().is_err() {
5159                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5160            }
5161            return Err(failure(
5162                NodeFailureCode::BackendOperationFailed,
5163                "session environment cleanup requires recovery",
5164            ));
5165        }
5166        let _transaction = self
5167            .state_transaction
5168            .lock()
5169            .unwrap_or_else(|poisoned| poisoned.into_inner());
5170        self.materializations
5171            .lock()
5172            .unwrap_or_else(|poisoned| poisoned.into_inner())
5173            .remove(id);
5174        if let Err(error) = self.persist_state_locked() {
5175            self.materializations
5176                .lock()
5177                .unwrap_or_else(|poisoned| poisoned.into_inner())
5178                .insert(id.clone(), ownership);
5179            return Err(persistence_failure(error));
5180        }
5181        Ok(())
5182    }
5183
5184    fn resolve_record_materialization(
5185        &self,
5186        record_id: &SessionRecordId,
5187        provider: &AgentId,
5188        mode: SessionMode,
5189        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5190        bundle: Option<&ResolvedBundleReceipt>,
5191        context: Option<&ResolvedContextPackReceipt>,
5192    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5193        let ownership = {
5194            let materializations = self
5195                .materializations
5196                .lock()
5197                .unwrap_or_else(|poisoned| poisoned.into_inner());
5198            let mut matches = materializations.values().filter(|record| {
5199                record.owner()
5200                    == &MaterializationOwner::Record {
5201                        record_id: record_id.clone(),
5202                    }
5203            });
5204            let ownership = matches.next().cloned();
5205            if matches.next().is_some() {
5206                return Err(failure(
5207                    NodeFailureCode::BackendOperationFailed,
5208                    "managed session has conflicting environment ownership",
5209                ));
5210            }
5211            ownership
5212        };
5213        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5214            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5215                failure(
5216                    NodeFailureCode::ContextPackMaterializationFailed,
5217                    "session context materializer is unavailable",
5218                )
5219            })?;
5220            let pack = materializer
5221                .revalidate_context(ownership, receipt)
5222                .map_err(|_| {
5223                    self.mark_materialization_recovery_required(ownership.id());
5224                    failure(
5225                        NodeFailureCode::ContextPackMaterializationFailed,
5226                        "managed session context pack revalidation failed",
5227                    )
5228                })?;
5229            self.context_catalog
5230                .write()
5231                .unwrap_or_else(|poisoned| poisoned.into_inner())
5232                .insert(pack)
5233                .map_err(|_| failure(
5234                    NodeFailureCode::ContextPackMaterializationFailed,
5235                    "managed session context pack catalog restore failed",
5236                ))?;
5237        }
5238        let materialization = self.materialization_profile(
5239            provider,
5240            mode,
5241            environment_profile,
5242            bundle,
5243            context,
5244        )?;
5245        match (ownership, materialization) {
5246            (None, None) => Ok(None),
5247            (None, Some(_)) | (Some(_), None) => Err(failure(
5248                NodeFailureCode::BackendOperationFailed,
5249                "managed session materialization is unavailable",
5250            )),
5251            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5252                if ownership.environment_profile() != environment_profile
5253                    || ownership.bundle() != bundle
5254                    || ownership.context() != context
5255                    || ownership.state() != MaterializationState::Ready
5256                {
5257                    self.mark_materialization_recovery_required(ownership.id());
5258                    return Err(failure(
5259                        NodeFailureCode::BackendOperationFailed,
5260                        "managed session environment ownership requires recovery",
5261                    ));
5262                }
5263                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5264                    failure(
5265                        NodeFailureCode::BackendOperationFailed,
5266                        "session environment materializer is unavailable",
5267                    )
5268                })?;
5269                if materializer.revalidate(&ownership).is_err() {
5270                    self.mark_materialization_recovery_required(ownership.id());
5271                    return Err(failure(
5272                        NodeFailureCode::BackendOperationFailed,
5273                        "managed session environment revalidation failed",
5274                    ));
5275                }
5276                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5277                    let catalog = self.bundle_catalog.read()
5278                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5279                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5280                        return Err(failure(
5281                            NodeFailureCode::UnknownBundle,
5282                            "managed session bundle is unavailable",
5283                        ));
5284                    };
5285                    let layout = bundle_layout.ok_or_else(|| failure(
5286                        NodeFailureCode::BundleBindingMismatch,
5287                        "record bundle provider layout is unavailable",
5288                    ))?;
5289                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5290                        drop(catalog);
5291                        self.mark_materialization_recovery_required(ownership.id());
5292                        return Err(failure(
5293                            NodeFailureCode::BundleMaterializationFailed,
5294                            "managed session bundle revalidation failed",
5295                        ));
5296                    }
5297                    Some(bundle_launch_arguments(
5298                        layout,
5299                        ownership.bundle_root(),
5300                        ownership.provider_home(),
5301                    ).map_err(|_| failure(
5302                        NodeFailureCode::BundleBindingMismatch,
5303                        "managed session bundle launch binding is invalid",
5304                    ))?)
5305                } else {
5306                    None
5307                };
5308                if !has_environment_materialization {
5309                    let overlay = bundle_arguments.map(|extra_args| {
5310                        NativeInstanceLaunchOverlay::new(
5311                            provider.clone(),
5312                            TransportKind::Pty,
5313                            Vec::new(),
5314                            extra_args,
5315                        )
5316                        .map(PreparedNativeLaunchOverlay::Instance)
5317                        .map_err(|_| failure(
5318                            NodeFailureCode::BundleBindingMismatch,
5319                            "managed session bundle launch overlay is invalid",
5320                        ))
5321                    }).transpose()?;
5322                    return Ok(Some((ownership.id().clone(), overlay)));
5323                }
5324                let environment = match materializer.resolve_environment(&ownership, &profile) {
5325                    Ok(environment) => environment,
5326                    Err(
5327                        SessionEnvironmentMaterializeError::SecretUnavailable
5328                        | SessionEnvironmentMaterializeError::SecretDenied
5329                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5330                    ) => {
5331                        return Err(failure(
5332                            NodeFailureCode::BackendOperationFailed,
5333                            "managed session environment resolution failed",
5334                        ));
5335                    }
5336                    Err(
5337                        SessionEnvironmentMaterializeError::InvalidRoot
5338                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5339                        | SessionEnvironmentMaterializeError::Filesystem(_)
5340                        | SessionEnvironmentMaterializeError::Record(_),
5341                    ) => {
5342                        self.mark_materialization_recovery_required(ownership.id());
5343                        return Err(failure(
5344                            NodeFailureCode::BackendOperationFailed,
5345                            "managed session environment resolution failed",
5346                        ));
5347                    }
5348                };
5349                let transport = match mode {
5350                    SessionMode::Pty => TransportKind::Pty,
5351                    SessionMode::Inline => TransportKind::Pipe,
5352                    SessionMode::Acp => TransportKind::Acp,
5353                };
5354                let overlay = match bundle_arguments {
5355                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5356                        NativeInstanceLaunchOverlay::new(
5357                            provider.clone(),
5358                            transport,
5359                            environment,
5360                            extra_args,
5361                        )
5362                        .map_err(|_| failure(
5363                            NodeFailureCode::BundleBindingMismatch,
5364                            "managed session bundle launch overlay is invalid",
5365                        ))?,
5366                    ),
5367                    None => PreparedNativeLaunchOverlay::Environment(
5368                        NativeLaunchEnvironmentOverlay::new(
5369                            provider.clone(),
5370                            transport,
5371                            environment,
5372                        )
5373                        .map_err(|_| {
5374                            failure(
5375                                NodeFailureCode::BackendOperationFailed,
5376                                "managed session environment overlay is invalid",
5377                            )
5378                        })?,
5379                    ),
5380                };
5381                Ok(Some((ownership.id().clone(), Some(overlay))))
5382            }
5383        }
5384    }
5385
5386    fn destroy_record_materialization(
5387        &self,
5388        record_id: &SessionRecordId,
5389    ) -> Result<bool, NodeFailure> {
5390        let ids = self
5391            .materializations
5392            .lock()
5393            .unwrap_or_else(|poisoned| poisoned.into_inner())
5394            .values()
5395            .filter_map(|record| {
5396                (record.owner()
5397                    == &MaterializationOwner::Record {
5398                        record_id: record_id.clone(),
5399                    })
5400                .then(|| record.id().clone())
5401            })
5402            .collect::<Vec<_>>();
5403        if ids.len() > 1 {
5404            return Err(failure(
5405                NodeFailureCode::BackendOperationFailed,
5406                "managed session has conflicting environment ownership",
5407            ));
5408        }
5409        let Some(id) = ids.first().cloned() else {
5410            return Ok(false);
5411        };
5412        let (terminal_record, mut cleanup_ownership) = {
5413            let _transaction = self
5414                .state_transaction
5415                .lock()
5416                .unwrap_or_else(|poisoned| poisoned.into_inner());
5417            let original_record = self.record(record_id)?;
5418            let mut terminal_record = original_record.clone();
5419            terminal_record.provider_session = None;
5420            terminal_record.active_session = None;
5421            terminal_record.state = ManagedSessionState::Unavailable;
5422            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5423            terminal_record.updated_at_unix_ms = unix_time_ms();
5424            let original_ownership = self
5425                .materializations
5426                .lock()
5427                .unwrap_or_else(|poisoned| poisoned.into_inner())
5428                .get(&id)
5429                .cloned()
5430                .ok_or_else(|| {
5431                    failure(
5432                        NodeFailureCode::BackendOperationFailed,
5433                        "managed session environment ownership disappeared during cleanup",
5434                    )
5435                })?;
5436            let mut cleanup_ownership = original_ownership.clone();
5437            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5438                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5439                    failure(
5440                        NodeFailureCode::BackendOperationFailed,
5441                        "managed session environment cleanup state is invalid",
5442                    )
5443                })?;
5444            }
5445            self.session_records
5446                .lock()
5447                .unwrap_or_else(|poisoned| poisoned.into_inner())
5448                .records
5449                .insert(record_id.clone(), terminal_record.clone());
5450            self.materializations
5451                .lock()
5452                .unwrap_or_else(|poisoned| poisoned.into_inner())
5453                .insert(id.clone(), cleanup_ownership.clone());
5454            if let Err(error) = self.persist_state_locked() {
5455                self.session_records
5456                    .lock()
5457                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5458                    .records
5459                    .insert(record_id.clone(), original_record);
5460                self.materializations
5461                    .lock()
5462                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5463                    .insert(id.clone(), original_ownership);
5464                return Err(persistence_failure(error));
5465            }
5466            (terminal_record, cleanup_ownership)
5467        };
5468        let cleanup = self
5469            .session_environment_materializer
5470            .as_ref()
5471            .ok_or(())
5472            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5473        if cleanup.is_err() {
5474            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5475            self.materializations
5476                .lock()
5477                .unwrap_or_else(|poisoned| poisoned.into_inner())
5478                .insert(id.clone(), cleanup_ownership);
5479            if self.persist_state().is_err() {
5480                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5481            }
5482            return Err(failure(
5483                NodeFailureCode::BackendOperationFailed,
5484                "managed session environment cleanup requires recovery",
5485            ));
5486        }
5487        let _transaction = self
5488            .state_transaction
5489            .lock()
5490            .unwrap_or_else(|poisoned| poisoned.into_inner());
5491        self.remove_record_memory(record_id);
5492        self.materializations
5493            .lock()
5494            .unwrap_or_else(|poisoned| poisoned.into_inner())
5495            .remove(&id);
5496        if let Err(error) = self.persist_state_locked() {
5497            self.session_records
5498                .lock()
5499                .unwrap_or_else(|poisoned| poisoned.into_inner())
5500                .records
5501                .insert(record_id.clone(), terminal_record);
5502            self.materializations
5503                .lock()
5504                .unwrap_or_else(|poisoned| poisoned.into_inner())
5505                .insert(id, cleanup_ownership);
5506            return Err(persistence_failure(error));
5507        }
5508        Ok(true)
5509    }
5510
5511    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5512        let changed = {
5513            let mut materializations = self
5514                .materializations
5515                .lock()
5516                .unwrap_or_else(|poisoned| poisoned.into_inner());
5517            let Some(ownership) = materializations.get_mut(id) else {
5518                return;
5519            };
5520            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5521        };
5522        if changed && self.persist_state().is_err() {
5523            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5524        }
5525    }
5526
5527    fn reconcile_materializations(&self) {
5528        let records = self
5529            .materializations
5530            .lock()
5531            .unwrap_or_else(|poisoned| poisoned.into_inner())
5532            .values()
5533            .cloned()
5534            .collect::<Vec<_>>();
5535        for mut ownership in records {
5536            let result = match (ownership.state(), ownership.owner()) {
5537                (MaterializationState::Preparing, _)
5538                | (MaterializationState::CleanupRequired, _) => {
5539                    self.cleanup_materialization(ownership.id())
5540                }
5541                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5542                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5543                        self.materializations
5544                            .lock()
5545                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5546                            .insert(ownership.id().clone(), ownership.clone());
5547                        self.persist_state().map_err(persistence_failure)
5548                    } else {
5549                        Err(failure(
5550                            NodeFailureCode::BackendOperationFailed,
5551                            "session environment reconciliation failed",
5552                        ))
5553                    }
5554                }
5555                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5556                    .session_environment_materializer
5557                    .as_ref()
5558                    .ok_or_else(|| {
5559                        failure(
5560                            NodeFailureCode::BackendOperationFailed,
5561                            "session environment materializer is unavailable",
5562                        )
5563                    })
5564                    .and_then(|materializer| {
5565                        materializer.revalidate(&ownership).map_err(|_| failure(
5566                            NodeFailureCode::BackendOperationFailed,
5567                            "session materialization revalidation failed",
5568                        ))?;
5569                        let record = self.record(record_id)?;
5570                        if record.environment_profile.as_ref() != ownership.environment_profile()
5571                            || record.bundle.as_ref() != ownership.bundle()
5572                            || record.context.as_ref() != ownership.context()
5573                        {
5574                            return Err(failure(
5575                                NodeFailureCode::BackendOperationFailed,
5576                                "session materialization ownership changed after restart",
5577                            ));
5578                        }
5579                        if let Some(receipt) = ownership.bundle() {
5580                            let catalog = self.bundle_catalog.read()
5581                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5582                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5583                                NodeFailureCode::UnknownBundle,
5584                                "session bundle is unavailable after restart",
5585                            ))?;
5586                            if bundle.receipt() != *receipt {
5587                                return Err(failure(
5588                                    NodeFailureCode::BundleBindingMismatch,
5589                                    "session bundle changed after restart",
5590                                ));
5591                            }
5592                            let layout = self.bundle_layout(
5593                                &record.provider,
5594                                record.mode,
5595                                record.environment_profile.as_ref(),
5596                                bundle,
5597                            )?;
5598                            materializer
5599                                .revalidate_bundle(&ownership, bundle, layout)
5600                                .map_err(|_| failure(
5601                                    NodeFailureCode::BundleMaterializationFailed,
5602                                    "session bundle revalidation failed",
5603                                ))?;
5604                        }
5605                        if let Some(receipt) = ownership.context() {
5606                            let pack = materializer
5607                                .revalidate_context(&ownership, receipt)
5608                                .map_err(|_| {
5609                                    failure(
5610                                        NodeFailureCode::ContextPackMaterializationFailed,
5611                                        "session context revalidation failed",
5612                                    )
5613                                })?;
5614                            self.context_catalog
5615                                .write()
5616                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5617                                .insert(pack)
5618                                .map_err(|_| {
5619                                    failure(
5620                                        NodeFailureCode::ContextPackMaterializationFailed,
5621                                        "session context catalog restore failed",
5622                                    )
5623                                })?;
5624                        }
5625                        Ok(())
5626                    }),
5627                (MaterializationState::RecoveryRequired, _) => Ok(()),
5628            };
5629            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5630                let _ = ownership.mark_recovery_required(unix_time_ms());
5631                self.materializations
5632                    .lock()
5633                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5634                    .insert(ownership.id().clone(), ownership);
5635                if self.persist_state().is_err() {
5636                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5637                }
5638            }
5639        }
5640        let materialized_profiles = self
5641            .environment_materialization_profiles
5642            .read()
5643            .unwrap_or_else(|poisoned| poisoned.into_inner())
5644            .keys()
5645            .cloned()
5646            .collect::<Vec<_>>();
5647        let ownership = self
5648            .materializations
5649            .lock()
5650            .unwrap_or_else(|poisoned| poisoned.into_inner())
5651            .values()
5652            .cloned()
5653            .collect::<Vec<_>>();
5654        let changed = {
5655            let mut session_records = self
5656                .session_records
5657                .lock()
5658                .unwrap_or_else(|poisoned| poisoned.into_inner());
5659            let mut changed = false;
5660            for record in session_records.records.values_mut() {
5661                let environment_profile = record.environment_profile.as_ref();
5662                if environment_profile.is_none()
5663                    && record.bundle.is_none()
5664                    && record.context.is_none()
5665                {
5666                    continue;
5667                }
5668                if record.bundle.is_none()
5669                    && environment_profile.is_some_and(|profile| {
5670                        !materialized_profiles.contains(&profile.profile_id)
5671                    })
5672                {
5673                    continue;
5674                }
5675                let has_exact_ownership = ownership.iter().any(|candidate| {
5676                    candidate.state() == MaterializationState::Ready
5677                        && candidate.environment_profile() == environment_profile
5678                        && candidate.bundle() == record.bundle.as_ref()
5679                        && candidate.context() == record.context.as_ref()
5680                        && candidate.owner()
5681                            == &MaterializationOwner::Record {
5682                                record_id: record.record_id.clone(),
5683                            }
5684                });
5685                if !has_exact_ownership {
5686                    record.provider_session = None;
5687                    record.active_session = None;
5688                    record.state = ManagedSessionState::Unavailable;
5689                    record.last_error = Some(if record.bundle.is_some() {
5690                        "bundle-unavailable".to_owned()
5691                    } else if record.context.is_some() {
5692                        "context-unavailable".to_owned()
5693                    } else {
5694                        "environment-profile-unavailable".to_owned()
5695                    });
5696                    record.updated_at_unix_ms = unix_time_ms();
5697                    changed = true;
5698                }
5699            }
5700            changed
5701        };
5702        if changed && self.persist_state().is_err() {
5703            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5704        }
5705    }
5706
5707    fn replay_spawn_spec(
5708        &self,
5709        spec: &SpawnSpec,
5710        now: Instant,
5711    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5712        let mut cache = self
5713            .spawn_idempotency
5714            .lock()
5715            .unwrap_or_else(|poisoned| poisoned.into_inner());
5716        cache.entries.retain(|_, entry| entry.expires_at > now);
5717        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5718            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5719                return Err(failure(
5720                    NodeFailureCode::SpawnIdempotencyConflict,
5721                    "spawn idempotency key was reused across spawn kinds",
5722                ));
5723            };
5724            if existing != spec {
5725                return Err(failure(
5726                    NodeFailureCode::SpawnIdempotencyConflict,
5727                    "spawn idempotency key was reused with a different specification",
5728                ));
5729            }
5730            return Ok(Some(result.clone()));
5731        }
5732        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5733            return Err(failure(
5734                NodeFailureCode::SpawnIdempotencyCapacity,
5735                "spawn idempotency cache reached its bounded capacity",
5736            ));
5737        }
5738        Ok(None)
5739    }
5740
5741    fn remember_spawn_spec(
5742        &self,
5743        spec: SpawnSpec,
5744        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5745        accepted_at: Instant,
5746    ) {
5747        self
5748            .spawn_idempotency
5749            .lock()
5750            .unwrap_or_else(|poisoned| poisoned.into_inner())
5751            .entries
5752            .insert(
5753                spec.idempotency_key.clone(),
5754                SpawnIdempotencyEntry {
5755                    value: SpawnIdempotencyValue::Standard { spec, result },
5756                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5757                },
5758            );
5759    }
5760
5761    fn claim_harness_mcp_spawn(
5762        &self,
5763        _reservation_id: &HarnessMcpReservationId,
5764        _activation_digest: &HarnessMcpActivationDigest,
5765        spec: &SpawnSpec,
5766        now: Instant,
5767    ) -> Result<(), NodeFailure> {
5768        let mut cache = self.spawn_idempotency.lock()
5769            .unwrap_or_else(|poisoned| poisoned.into_inner());
5770        cache.entries.retain(|_, entry| entry.expires_at > now);
5771        if cache.entries.contains_key(&spec.idempotency_key) {
5772            return Err(failure(
5773                NodeFailureCode::SpawnIdempotencyConflict,
5774                "spawn idempotency key was reused across spawn kinds",
5775            ));
5776        }
5777        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5778            return Err(failure(
5779                NodeFailureCode::SpawnIdempotencyCapacity,
5780                "spawn idempotency cache reached its bounded capacity",
5781            ));
5782        }
5783        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5784            value: SpawnIdempotencyValue::HarnessMcp,
5785            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5786        });
5787        Ok(())
5788    }
5789
5790    fn replay_managed_spawn(
5791        &self,
5792        request: &ManagedWorktreeSpawnRequest,
5793        now: Instant,
5794    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5795        let mut cache = self.spawn_idempotency.lock()
5796            .unwrap_or_else(|poisoned| poisoned.into_inner());
5797        cache.entries.retain(|_, entry| entry.expires_at > now);
5798        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5799            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5800                return Err(failure(
5801                    NodeFailureCode::SpawnIdempotencyConflict,
5802                    "spawn idempotency key was reused across spawn kinds",
5803                ));
5804            };
5805            if existing != request {
5806                return Err(failure(
5807                    NodeFailureCode::SpawnIdempotencyConflict,
5808                    "spawn idempotency key was reused with a different managed specification",
5809                ));
5810            }
5811            return Ok(Some(result.clone()));
5812        }
5813        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5814            return Err(failure(
5815                NodeFailureCode::SpawnIdempotencyCapacity,
5816                "spawn idempotency cache reached its bounded capacity",
5817            ));
5818        }
5819        Ok(None)
5820    }
5821
5822    fn remember_managed_spawn(
5823        &self,
5824        request: ManagedWorktreeSpawnRequest,
5825        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5826        accepted_at: Instant,
5827    ) {
5828        self.spawn_idempotency.lock()
5829            .unwrap_or_else(|poisoned| poisoned.into_inner())
5830            .entries
5831            .insert(
5832                request.spawn_spec.idempotency_key.clone(),
5833                SpawnIdempotencyEntry {
5834                    value: SpawnIdempotencyValue::Managed { request, result },
5835                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5836                },
5837            );
5838    }
5839
5840    fn remember_managed_spawn_attempt(
5841        &self,
5842        durable_v2_key: Option<&SpawnIdempotencyKey>,
5843        request: ManagedWorktreeSpawnRequest,
5844        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5845        accepted_at: Instant,
5846    ) {
5847        if durable_v2_key.is_none() {
5848            self.remember_managed_spawn(request, result, accepted_at);
5849        }
5850    }
5851
5852    fn acquire_durable_managed_spawn_v2(
5853        &self,
5854        request: &ManagedWorktreeSpawnRequestV2,
5855        request_digest: String,
5856    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5857        if self.state_path.is_none() {
5858            return Err(failure(
5859                NodeFailureCode::BackendOperationFailed,
5860                "managed worktree V2 requires durable node state",
5861            ));
5862        }
5863        let _transaction = self.state_transaction
5864            .lock()
5865            .unwrap_or_else(|poisoned| poisoned.into_inner());
5866        let key = request.spawn_spec.idempotency_key.clone();
5867        let mut previous = None;
5868        {
5869            let mut replays = self.managed_spawn_replays
5870                .lock()
5871                .unwrap_or_else(|poisoned| poisoned.into_inner());
5872            if let Some(record) = replays.get_mut(&key) {
5873                if record.request_digest != request_digest {
5874                    return Err(failure(
5875                        NodeFailureCode::SpawnIdempotencyConflict,
5876                        "managed worktree V2 idempotency key was reused with a different request",
5877                    ));
5878                }
5879                match &record.state {
5880                    ManagedWorktreeSpawnReplayStateV10::Pending {
5881                        owner_incarnation_id,
5882                        lease_id: None,
5883                    } if owner_incarnation_id != &self.incarnation_id => {
5884                        previous = Some(record.clone());
5885                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5886                            owner_incarnation_id: self.incarnation_id,
5887                            lease_id: None,
5888                        };
5889                        record.updated_at_unix_ms = unix_time_ms();
5890                    }
5891                    ManagedWorktreeSpawnReplayStateV10::Pending {
5892                        lease_id: None,
5893                        ..
5894                    } => {
5895                        return Err(failure(
5896                            NodeFailureCode::BackendBusy,
5897                            "managed worktree V2 reservation is already active",
5898                        ));
5899                    }
5900                    ManagedWorktreeSpawnReplayStateV10::Pending {
5901                        lease_id: Some(_),
5902                        ..
5903                    } => {
5904                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5905                    }
5906                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5907                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5908                            receipt.clone(),
5909                        ));
5910                    }
5911                }
5912            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5913                return Err(failure(
5914                    NodeFailureCode::SpawnIdempotencyCapacity,
5915                    "durable managed worktree replay ledger reached its bounded capacity",
5916                ));
5917            } else {
5918                let now = unix_time_ms();
5919                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5920                    idempotency_key: key.clone(),
5921                    request_digest,
5922                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5923                    profile_id: request.worktree_profile_id.clone(),
5924                    expected_profile_revision: request.expected_profile_revision.clone(),
5925                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5926                        owner_incarnation_id: self.incarnation_id,
5927                        lease_id: None,
5928                    },
5929                    created_at_unix_ms: now,
5930                    updated_at_unix_ms: now,
5931                });
5932            }
5933        }
5934        if let Err(error) = self.persist_state_locked() {
5935            let mut replays = self.managed_spawn_replays
5936                .lock()
5937                .unwrap_or_else(|poisoned| poisoned.into_inner());
5938            match previous {
5939                Some(previous) => {
5940                    replays.insert(key, previous);
5941                }
5942                None => {
5943                    replays.remove(&key);
5944                }
5945            }
5946            return Err(persistence_failure(error));
5947        }
5948        Ok(DurableManagedSpawnReplayDecision::Reserved)
5949    }
5950
5951    fn link_durable_managed_spawn_v2_lease_locked(
5952        &self,
5953        key: &SpawnIdempotencyKey,
5954        lease_id: &ManagedWorktreeLeaseId,
5955    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5956        let mut replays = self.managed_spawn_replays
5957            .lock()
5958            .unwrap_or_else(|poisoned| poisoned.into_inner());
5959        let record = replays.get_mut(key).ok_or_else(|| {
5960            failure(
5961                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5962                "managed worktree V2 reservation disappeared before allocation",
5963            )
5964        })?;
5965        let previous = record.clone();
5966        match &mut record.state {
5967            ManagedWorktreeSpawnReplayStateV10::Pending {
5968                lease_id: current,
5969                ..
5970            }
5971                if current.is_none() =>
5972            {
5973                *current = Some(lease_id.clone());
5974                record.updated_at_unix_ms = unix_time_ms();
5975                Ok(previous)
5976            }
5977            _ => Err(failure(
5978                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5979                "managed worktree V2 reservation is not allocation-ready",
5980            )),
5981        }
5982    }
5983
5984    fn restore_durable_managed_spawn_v2_locked(
5985        &self,
5986        key: &SpawnIdempotencyKey,
5987        previous: ManagedWorktreeSpawnReplayRecordV10,
5988    ) {
5989        self.managed_spawn_replays
5990            .lock()
5991            .unwrap_or_else(|poisoned| poisoned.into_inner())
5992            .insert(key.clone(), previous);
5993    }
5994
5995    fn commit_durable_managed_spawn_v2(
5996        &self,
5997        request: &ManagedWorktreeSpawnRequestV2,
5998        receipt: ManagedWorktreeSpawnReceipt,
5999    ) -> Result<(), NodeFailure> {
6000        let _transaction = self.state_transaction
6001            .lock()
6002            .unwrap_or_else(|poisoned| poisoned.into_inner());
6003        let key = &request.spawn_spec.idempotency_key;
6004        let previous = {
6005            let mut replays = self.managed_spawn_replays
6006                .lock()
6007                .unwrap_or_else(|poisoned| poisoned.into_inner());
6008            let record = replays.get_mut(key).ok_or_else(|| {
6009                failure(
6010                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6011                    "managed worktree V2 reservation disappeared before commit",
6012                )
6013            })?;
6014            let previous = record.clone();
6015            match &record.state {
6016                ManagedWorktreeSpawnReplayStateV10::Pending {
6017                    lease_id: Some(lease_id),
6018                    ..
6019                } if lease_id == &receipt.lease.lease_id => {}
6020                _ => {
6021                    return Err(failure(
6022                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6023                        "managed worktree V2 reservation is not linked to the spawned lease",
6024                    ));
6025                }
6026            }
6027            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6028            record.updated_at_unix_ms = unix_time_ms();
6029            previous
6030        };
6031        if let Err(error) = self.persist_state_locked() {
6032            self.restore_durable_managed_spawn_v2_locked(key, previous);
6033            return Err(persistence_failure(error));
6034        }
6035        Ok(())
6036    }
6037
6038    fn remove_unallocated_durable_managed_spawn_v2(
6039        &self,
6040        request: &ManagedWorktreeSpawnRequestV2,
6041    ) -> Result<(), NodeFailure> {
6042        let _transaction = self.state_transaction
6043            .lock()
6044            .unwrap_or_else(|poisoned| poisoned.into_inner());
6045        let key = &request.spawn_spec.idempotency_key;
6046        let removed = {
6047            let mut replays = self.managed_spawn_replays
6048                .lock()
6049                .unwrap_or_else(|poisoned| poisoned.into_inner());
6050            match replays.get(key).map(|record| &record.state) {
6051                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6052                    lease_id: None,
6053                    ..
6054                }) => {
6055                    replays.remove(key)
6056                }
6057                _ => None,
6058            }
6059        };
6060        let Some(removed) = removed else {
6061            return Ok(());
6062        };
6063        if let Err(error) = self.persist_state_locked() {
6064            self.managed_spawn_replays
6065                .lock()
6066                .unwrap_or_else(|poisoned| poisoned.into_inner())
6067                .insert(key.clone(), removed);
6068            return Err(persistence_failure(error));
6069        }
6070        Ok(())
6071    }
6072
6073    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6074        if spec.target.node_id != self.node_id {
6075            return Err(failure(
6076                NodeFailureCode::SpawnTargetMismatch,
6077                "spawn target does not match this node",
6078            ));
6079        }
6080        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6081            failure(
6082                NodeFailureCode::UnknownSpawnProfile,
6083                "spawn profile is unavailable on this node",
6084            )
6085        })?;
6086        let resolved = spec.resolve(defaults).map_err(|error| match error {
6087            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6088                NodeFailureCode::SpawnProfileRevisionMismatch,
6089                "spawn profile revision does not match the loaded profile",
6090            ),
6091            _ => failure(
6092                NodeFailureCode::InvalidRequest,
6093                "spawn specification could not be resolved",
6094            ),
6095        })?;
6096        if resolved.target.worktree_id.is_some() {
6097            self.require_worktree_service(&resolved.target.workspace_id)?;
6098        }
6099        let environment_profile = self.resolve_environment_profile(&resolved)?;
6100        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6101        self.resolve_context(&resolved)?;
6102        Ok(resolved)
6103    }
6104
6105    async fn resolve_spawn_workspace(
6106        &self,
6107        resolved: &ResolvedSpawnSpec,
6108        deadline: Instant,
6109    ) -> Result<WorkspaceId, NodeFailure> {
6110        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6111            return Ok(resolved.target.workspace_id.clone());
6112        };
6113        if self.managed_worktrees.lock()
6114            .unwrap_or_else(|poisoned| poisoned.into_inner())
6115            .lease_for_workspace(worktree_id)
6116            .is_some()
6117        {
6118            return Err(failure(
6119                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6120                "managed worktree targets may only be spawned through their lease request",
6121            ));
6122        }
6123        self.require_worktree_service(&resolved.target.workspace_id)?;
6124        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6125        let selected_root = self.workspace_root(worktree_id)?;
6126        let remaining = spawn_deadline_remaining(deadline)?;
6127        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6128            .await
6129            .map_err(|_| {
6130                failure(
6131                    NodeFailureCode::SpawnDeadlineExceeded,
6132                    "Git worktree selection exceeded the spawn deadline",
6133                )
6134            })?
6135            .map_err(git_worktree_failure)?;
6136        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6137        Ok(worktree_id.clone())
6138    }
6139
6140    async fn spawn_from_spec(
6141        &self,
6142        spec: SpawnSpec,
6143    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6144        self.spawn_from_spec_with_proxy(spec, None).await
6145    }
6146
6147    async fn spawn_from_spec_with_proxy(
6148        &self,
6149        spec: SpawnSpec,
6150        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6151    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6152        let accepted_at = Instant::now();
6153        if harness_mcp.is_none() {
6154            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6155                return replayed;
6156            }
6157        }
6158        let resolved = self.resolve_spawn_spec(&spec)?;
6159        let environment_profile = self.resolve_environment_profile(&resolved)?;
6160        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6161        let context = self.resolve_context(&resolved)?;
6162        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6163        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6164        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6165        let spawn_workspace_id = match spawn_workspace_id {
6166            Ok(workspace_id) => workspace_id,
6167            Err(error) => {
6168                let result = Err(error);
6169                if harness_mcp.is_none() {
6170                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6171                }
6172                return result;
6173            }
6174        };
6175        if self.managed_worktrees.lock()
6176            .unwrap_or_else(|poisoned| poisoned.into_inner())
6177            .lease_for_workspace(&spawn_workspace_id)
6178            .is_some()
6179        {
6180            let result = Err(failure(
6181                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6182                "managed worktree targets may only be spawned through their lease request",
6183            ));
6184            if harness_mcp.is_none() {
6185                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6186            }
6187            return result;
6188        }
6189        let result = self
6190            .spawn_session_with_deadline(
6191                spawn_workspace_id,
6192                resolved.provider.clone(),
6193                resolved.mode,
6194                resolved.terminal_size,
6195                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6196                environment_profile.clone(),
6197                bundle.clone(),
6198                context.clone(),
6199                None,
6200                None,
6201                SpawnRecordPolicy::Always,
6202                Some(deadline),
6203                &required_capabilities,
6204                harness_mcp,
6205                resolved.approval_level,
6206                resolved.network_allowlist.clone(),
6207                resolved.browser_profile_id.clone(),
6208            )
6209            .await
6210            .map(|(session, _runtime_policy)| {
6211                resolved.receipt_with_materialization(
6212                    self.incarnation_id,
6213                    session,
6214                    environment_profile,
6215                    bundle,
6216                    context,
6217                )
6218            });
6219        if harness_mcp.is_none() {
6220            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6221        }
6222        result
6223    }
6224
6225    async fn spawn_from_spec_with_harness_mcp(
6226        &self,
6227        reservation_id: HarnessMcpReservationId,
6228        activation_digest: HarnessMcpActivationDigest,
6229        spec: SpawnSpec,
6230        deadline_unix_ms: u64,
6231    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6232        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6233            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6234        })?;
6235        if let Some(receipt) = registry
6236            .replay_spawn(&reservation_id, &activation_digest, &spec)
6237            .map_err(harness_mcp_failure)?
6238        {
6239            return Ok(receipt);
6240        }
6241        let resolved = self.resolve_spawn_spec(&spec)?;
6242        let prepared = registry
6243            .prepare_spawn(
6244                &reservation_id,
6245                &activation_digest,
6246                &spec,
6247                deadline_unix_ms,
6248                resolved.provider.clone(),
6249            )
6250            .map_err(harness_mcp_failure)?;
6251        self.claim_harness_mcp_spawn(
6252            &reservation_id,
6253            &activation_digest,
6254            &spec,
6255            Instant::now(),
6256        )?;
6257        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6258        let mut receipt = match spawn {
6259            Ok(receipt) => receipt,
6260            Err(error) => {
6261                let _ = registry.abort(&reservation_id, &activation_digest);
6262                return Err(error);
6263            }
6264        };
6265        let record_id = self
6266            .session_bindings
6267            .lock()
6268            .unwrap_or_else(|poisoned| poisoned.into_inner())
6269            .get(&receipt.session.session.instance_id)
6270            .and_then(|binding| binding.record_id.clone());
6271        let record_id = match record_id {
6272            Some(record_id) => record_id,
6273            None => {
6274                let rollback = self.remove_session(&receipt.session).await;
6275                let _ = registry.abort(&reservation_id, &activation_digest);
6276                if let Err(rollback_error) = rollback {
6277                    return Err(rollback_error);
6278                }
6279                return Err(failure(
6280                    NodeFailureCode::BindingMismatch,
6281                    "harness MCP spawn did not retain its exact managed record",
6282                ));
6283            }
6284        };
6285        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6286            reservation_id: reservation_id.clone(),
6287            activation_digest: activation_digest.clone(),
6288        });
6289        if let Err(error) = registry.mark_spawned(
6290            &prepared,
6291            receipt.session.clone(),
6292            record_id,
6293            receipt.clone(),
6294        ) {
6295            let rollback = self.remove_session(&receipt.session).await;
6296            let _ = registry.abort(&reservation_id, &activation_digest);
6297            if let Err(rollback_error) = rollback {
6298                return Err(rollback_error);
6299            }
6300            return Err(harness_mcp_failure(error));
6301        }
6302        Ok(receipt)
6303    }
6304
6305    async fn spawn_managed_worktree(
6306        &self,
6307        request: ManagedWorktreeSpawnRequest,
6308    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6309        self.spawn_managed_worktree_inner(request, None).await
6310    }
6311
6312    async fn spawn_managed_worktree_inner(
6313        &self,
6314        request: ManagedWorktreeSpawnRequest,
6315        durable_v2_key: Option<&SpawnIdempotencyKey>,
6316    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6317        let accepted_at = Instant::now();
6318        if durable_v2_key.is_none() {
6319            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6320                return replayed;
6321            }
6322        }
6323        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6324        if request.spawn_spec.target.worktree_id.is_some() {
6325            let result = Err(failure(
6326                NodeFailureCode::InvalidRequest,
6327                "managed worktree spawn must not select a caller-provided worktree",
6328            ));
6329            self.remember_managed_spawn_attempt(
6330                durable_v2_key,
6331                request,
6332                result.clone(),
6333                accepted_at,
6334            );
6335            return result;
6336        }
6337        let environment_profile = self.resolve_environment_profile(&resolved)?;
6338        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6339        let context = self.resolve_context(&resolved)?;
6340        let source_workspace_id = resolved.target.workspace_id.clone();
6341        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6342        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6343        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6344        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6345            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6346            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6347            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6348            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6349        };
6350        let runtime_policy = timeout(
6351            spawn_deadline_remaining(deadline)?,
6352            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6353        ).await.map_err(|_| failure(
6354            NodeFailureCode::SpawnDeadlineExceeded,
6355            "provider admission exceeded the managed spawn deadline",
6356        ))??;
6357        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6358            return Err(failure(
6359                NodeFailureCode::UnsupportedSpawnCapability,
6360                "provider runtime does not admit a required spawn capability",
6361            ));
6362        }
6363        self.ensure_binding_capacity()?;
6364        let source_root = self.workspace_root(&source_workspace_id)?;
6365        profile.validate_for_workspace(&source_root)
6366            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6367        let base_timeout_ms = u64::try_from(
6368            spawn_deadline_remaining(deadline)?.as_millis(),
6369        ).unwrap_or(u64::MAX).max(1);
6370        let base_commit = resolve_base_commit_with_timeout(
6371            &source_root,
6372            profile.base(),
6373            base_timeout_ms,
6374        ).await.map_err(|error| {
6375            if Instant::now() >= deadline || error.message.contains("timed out") {
6376                failure(
6377                    NodeFailureCode::SpawnDeadlineExceeded,
6378                    "managed worktree base resolution exceeded the spawn deadline",
6379                )
6380            } else {
6381                managed_git_worktree_failure(error)
6382            }
6383        })?;
6384
6385        let lease = {
6386            let _transaction = self.state_transaction.lock()
6387                .unwrap_or_else(|poisoned| poisoned.into_inner());
6388            let previous = self.managed_worktrees.lock()
6389                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6390            let lease = self.managed_worktrees.lock()
6391                .unwrap_or_else(|poisoned| poisoned.into_inner())
6392                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6393                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6394            let previous_replay = match durable_v2_key
6395                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6396                .transpose()
6397            {
6398                Ok(previous) => previous,
6399                Err(error) => {
6400                    *self.managed_worktrees.lock()
6401                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6402                    return Err(error);
6403                }
6404            };
6405            if let Err(error) = self.persist_state_locked() {
6406                *self.managed_worktrees.lock()
6407                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6408                if let (Some(key), Some(previous_replay)) =
6409                    (durable_v2_key, previous_replay)
6410                {
6411                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6412                }
6413                return Err(persistence_failure(error));
6414            }
6415            lease
6416        };
6417        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6418        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6419            self.managed_worktrees.lock()
6420                .unwrap_or_else(|poisoned| poisoned.into_inner())
6421                .remove_unmutated(&lease.lease_id);
6422            if self.persist_state().is_err() {
6423                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6424            }
6425            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6426            self.remember_managed_spawn_attempt(
6427                durable_v2_key,
6428                request,
6429                result.clone(),
6430                accepted_at,
6431            );
6432            return result;
6433        }
6434        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6435            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6436            Err(error) => {
6437                self.reconcile_failed_allocation(&lease, &source_root).await;
6438                let result = Err(error);
6439                self.remember_managed_spawn_attempt(
6440                    durable_v2_key,
6441                    request,
6442                    result.clone(),
6443                    accepted_at,
6444                );
6445                return result;
6446            }
6447        };
6448        let created = match create_git_worktree_with_timeout(
6449                &source_root,
6450                &lease.target_root,
6451                &lease.branch,
6452                Some(&base_commit),
6453                mutation_timeout_ms,
6454            ).await {
6455            Ok(created) => created,
6456            Err(error) => {
6457                self.reconcile_failed_allocation(&lease, &source_root).await;
6458                let result = Err(if Instant::now() >= deadline
6459                    || error.message.contains("timed out")
6460                    || error.message.contains("deadline")
6461                {
6462                    failure(
6463                        NodeFailureCode::SpawnDeadlineExceeded,
6464                        "managed worktree creation exceeded the spawn deadline",
6465                    )
6466                } else {
6467                    managed_git_worktree_failure(error)
6468                });
6469                self.remember_managed_spawn_attempt(
6470                    durable_v2_key,
6471                    request,
6472                    result.clone(),
6473                    accepted_at,
6474                );
6475                return result;
6476            }
6477        };
6478        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6479            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6480            let result = Err(failure(
6481                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6482                "created worktree identity did not match its durable lease",
6483            ));
6484            self.remember_managed_spawn_attempt(
6485                durable_v2_key,
6486                request,
6487                result.clone(),
6488                accepted_at,
6489            );
6490            return result;
6491        }
6492        {
6493            let mut registry = self.managed_worktrees.lock()
6494                .unwrap_or_else(|poisoned| poisoned.into_inner());
6495            let current = registry.get_mut(&lease.lease_id)
6496                .expect("allocated managed worktree lease remains present");
6497            current.expected_head = Some(created.head.clone());
6498            current.state = ManagedWorktreeLeaseState::Ready;
6499            current.updated_at_unix_ms = unix_time_ms();
6500        }
6501        if let Err(error) = self.persist_state() {
6502            self.mark_managed_recovery_required(
6503                &lease.lease_id,
6504                ManagedWorktreeCleanupFailure::Backend,
6505            );
6506            let result = Err(persistence_failure(error));
6507            self.remember_managed_spawn_attempt(
6508                durable_v2_key,
6509                request,
6510                result.clone(),
6511                accepted_at,
6512            );
6513            return result;
6514        }
6515        let ready = self.managed_worktrees.lock()
6516            .unwrap_or_else(|poisoned| poisoned.into_inner())
6517            .get(&lease.lease_id)
6518            .expect("ready managed worktree lease remains present")
6519            .snapshot();
6520        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6521        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6522            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6523            let result = Err(error);
6524            self.remember_managed_spawn_attempt(
6525                durable_v2_key,
6526                request,
6527                result.clone(),
6528                accepted_at,
6529            );
6530            return result;
6531        }
6532        let spawn = self.spawn_session_with_deadline(
6533            lease.workspace_id.clone(),
6534            resolved.provider.clone(),
6535            resolved.mode,
6536            resolved.terminal_size,
6537            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6538            environment_profile.clone(),
6539            bundle.clone(),
6540            context.clone(),
6541            Some(lease.lease_id.clone()),
6542            Some(runtime_policy),
6543            SpawnRecordPolicy::Always,
6544            Some(deadline),
6545            &required_capabilities,
6546            None,
6547            resolved.approval_level,
6548            resolved.network_allowlist.clone(),
6549            resolved.browser_profile_id.clone(),
6550        ).await;
6551        let (session, effective_runtime_policy) = match spawn {
6552            Ok(spawned) => spawned,
6553            Err(error) => {
6554                if self
6555                    .cleanup_managed_worktree(&lease.lease_id, true)
6556                    .await
6557                    .is_err()
6558                {
6559                    self.mark_managed_recovery_required(
6560                        &lease.lease_id,
6561                        ManagedWorktreeCleanupFailure::Backend,
6562                    );
6563                }
6564                let result = Err(error);
6565                self.remember_managed_spawn_attempt(
6566                    durable_v2_key,
6567                    request,
6568                    result.clone(),
6569                    accepted_at,
6570                );
6571                return result;
6572            }
6573        };
6574        let (record_id, raw_inventory_record_id) = {
6575            let mut bindings = self.session_bindings.lock()
6576                .unwrap_or_else(|poisoned| poisoned.into_inner());
6577            let binding = bindings.get_mut(&session.session.instance_id)
6578                .expect("successful spawn retains its session binding");
6579            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6580            managed_spawn_record_ownership(binding, effective_runtime_policy)
6581        };
6582        let bound = self.managed_worktrees.lock()
6583            .unwrap_or_else(|poisoned| poisoned.into_inner())
6584            .bind_session(
6585                &lease.lease_id,
6586                ManagedWorktreeSessionHolder {
6587                    incarnation_id: self.incarnation_id,
6588                    instance_id: session.session.instance_id,
6589                    generation: session.session.generation,
6590                },
6591                record_id,
6592                unix_time_ms(),
6593            )
6594            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6595        let snapshot = match bound {
6596            Ok(snapshot) => snapshot,
6597            Err(error) => {
6598                if raw_inventory_record_id
6599                    .as_ref()
6600                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6601                {
6602                    self.mark_managed_recovery_required(
6603                        &lease.lease_id,
6604                        ManagedWorktreeCleanupFailure::Backend,
6605                    );
6606                }
6607                let _ = self.remove_session(&session).await;
6608                self.mark_managed_recovery_required(
6609                    &lease.lease_id,
6610                    ManagedWorktreeCleanupFailure::Backend,
6611                );
6612                let result = Err(error);
6613                self.remember_managed_spawn_attempt(
6614                    durable_v2_key,
6615                    request,
6616                    result.clone(),
6617                    accepted_at,
6618                );
6619                return result;
6620            }
6621        };
6622        if let Err(error) = self.persist_state() {
6623            if raw_inventory_record_id
6624                .as_ref()
6625                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6626            {
6627                self.mark_managed_recovery_required(
6628                    &lease.lease_id,
6629                    ManagedWorktreeCleanupFailure::Backend,
6630                );
6631            }
6632            let _ = self.remove_session(&session).await;
6633            if self
6634                .cleanup_managed_worktree(&lease.lease_id, true)
6635                .await
6636                .is_err()
6637            {
6638                self.mark_managed_recovery_required(
6639                    &lease.lease_id,
6640                    ManagedWorktreeCleanupFailure::Backend,
6641                );
6642            }
6643            let result = Err(persistence_failure(error));
6644            self.remember_managed_spawn_attempt(
6645                durable_v2_key,
6646                request,
6647                result.clone(),
6648                accepted_at,
6649            );
6650            return result;
6651        }
6652        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6653        let receipt = managed_spawn_receipt(
6654            &resolved,
6655            self.incarnation_id,
6656            session,
6657            snapshot,
6658            environment_profile,
6659            bundle,
6660            context,
6661        );
6662        let result = Ok(receipt);
6663        self.remember_managed_spawn_attempt(
6664            durable_v2_key,
6665            request,
6666            result.clone(),
6667            accepted_at,
6668        );
6669        result
6670    }
6671
6672    async fn spawn_managed_worktree_v2(
6673        &self,
6674        request: ManagedWorktreeSpawnRequestV2,
6675    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6676        if request.spawn_spec.target.node_id != self.node_id {
6677            return Err(failure(
6678                NodeFailureCode::SpawnTargetMismatch,
6679                "spawn target does not match this node",
6680            ));
6681        }
6682        if request.spawn_spec.target.worktree_id.is_some() {
6683            return Err(failure(
6684                NodeFailureCode::InvalidRequest,
6685                "managed worktree spawn must not select a caller-provided worktree",
6686            ));
6687        }
6688        let request_digest = managed_spawn_request_digest_v2(&request)?;
6689        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6690        match replay {
6691            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6692            DurableManagedSpawnReplayDecision::PendingLinked => {
6693                return Err(failure(
6694                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6695                    "managed worktree V2 reservation has an incomplete durable allocation",
6696                ));
6697            }
6698            DurableManagedSpawnReplayDecision::Reserved => {}
6699        }
6700        let validated = (|| {
6701            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6702            let profile = self.managed_profile(
6703                &resolved.target.workspace_id,
6704                &request.worktree_profile_id,
6705            )?;
6706            if profile.revision() != &request.expected_profile_revision {
6707                return Err(failure(
6708                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6709                    "managed worktree profile revision does not match the loaded profile",
6710                ));
6711            }
6712            Ok(())
6713        })();
6714        match validated {
6715            Ok(()) => {}
6716            Err(error) => {
6717                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6718                return Err(error);
6719            }
6720        }
6721        let legacy = ManagedWorktreeSpawnRequest {
6722            spawn_spec: request.spawn_spec.clone(),
6723            worktree_profile_id: request.worktree_profile_id.clone(),
6724        };
6725        match self
6726            .spawn_managed_worktree_inner(
6727                legacy,
6728                Some(&request.spawn_spec.idempotency_key),
6729            )
6730            .await
6731        {
6732            Ok(receipt) => {
6733                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6734                Ok(receipt)
6735            }
6736            Err(error) => {
6737                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6738                Err(error)
6739            }
6740        }
6741    }
6742
6743    fn managed_profile(
6744        &self,
6745        workspace_id: &WorkspaceId,
6746        profile_id: &WorktreeProfileId,
6747    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6748        if self.worktree_service_modes.get(workspace_id).copied()
6749            != Some(WorktreeServiceMode::Managed)
6750        {
6751            return Err(failure(
6752                NodeFailureCode::UnsupportedSpawnCapability,
6753                "workspace does not enable managed worktrees",
6754            ));
6755        }
6756        self.managed_worktree_profiles.get(workspace_id)
6757            .and_then(|profiles| profiles.get(profile_id))
6758            .cloned()
6759            .ok_or_else(|| failure(
6760                NodeFailureCode::InvalidRequest,
6761                "managed worktree profile is unavailable for this workspace",
6762            ))
6763    }
6764
6765    async fn reconcile_failed_allocation(
6766        &self,
6767        lease: &ManagedWorktreeLeaseRecord,
6768        source_root: &str,
6769    ) {
6770        match list_git_worktrees(source_root).await {
6771            Ok(worktrees) => {
6772                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6773                    None => {
6774                        match std::fs::symlink_metadata(&lease.target_root) {
6775                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6776                                self.managed_worktrees.lock()
6777                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6778                                    .remove_unmutated(&lease.lease_id);
6779                                let _ = self.persist_state();
6780                            }
6781                            _ => self.mark_managed_recovery_required(
6782                                &lease.lease_id,
6783                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6784                            ),
6785                        }
6786                    }
6787                    Some(item) if exact_owned_worktree(
6788                        lease,
6789                        &item.path,
6790                        item.branch.as_deref(),
6791                        &item.head,
6792                    ) => {
6793                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6794                    }
6795                    Some(_) => self.mark_managed_recovery_required(
6796                        &lease.lease_id,
6797                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6798                    ),
6799                }
6800            }
6801            Err(_) => self.mark_managed_recovery_required(
6802                &lease.lease_id,
6803                ManagedWorktreeCleanupFailure::Backend,
6804            ),
6805        }
6806    }
6807
6808    async fn reconcile_managed_worktrees(&self) {
6809        let leases = self.managed_worktrees.lock()
6810            .unwrap_or_else(|poisoned| poisoned.into_inner())
6811            .active_records()
6812            .cloned()
6813            .collect::<Vec<_>>();
6814        for lease in leases {
6815            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6816                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6817                _ => {
6818                    self.mark_managed_recovery_required(
6819                        &lease.lease_id,
6820                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6821                    );
6822                    continue;
6823                }
6824            };
6825            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6826                self.mark_managed_recovery_required(
6827                    &lease.lease_id,
6828                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6829                );
6830                continue;
6831            }
6832            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6833                Ok(root) => root,
6834                Err(_) => {
6835                    self.mark_managed_recovery_required(
6836                        &lease.lease_id,
6837                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6838                    );
6839                    continue;
6840                }
6841            };
6842            let worktrees = match list_git_worktrees(&source_root).await {
6843                Ok(worktrees) => worktrees,
6844                Err(_) => {
6845                    self.mark_managed_recovery_required(
6846                        &lease.lease_id,
6847                        ManagedWorktreeCleanupFailure::Backend,
6848                    );
6849                    continue;
6850                }
6851            };
6852            let listed = worktrees.iter()
6853                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6854            let Some(listed) = listed else {
6855                match std::fs::symlink_metadata(&lease.target_root) {
6856                    Err(error) if error.kind() == io::ErrorKind::NotFound
6857                        && lease.state == ManagedWorktreeLeaseState::Allocating
6858                        && !lease.has_holders() => {
6859                            let removed = self.managed_worktrees.lock()
6860                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6861                                .tombstone(&lease.lease_id, unix_time_ms());
6862                            if self.persist_state().is_ok() && removed.is_some() {
6863                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6864                                    lease_id: lease.lease_id.clone(),
6865                                });
6866                            }
6867                        }
6868                    _ => self.mark_managed_recovery_required(
6869                        &lease.lease_id,
6870                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6871                    ),
6872                }
6873                continue;
6874            };
6875            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6876                &lease,
6877                &listed.path,
6878                listed.branch.as_deref(),
6879                &listed.head,
6880            ) {
6881                self.mark_managed_recovery_required(
6882                    &lease.lease_id,
6883                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6884                );
6885                continue;
6886            }
6887            if !self.workspaces.read()
6888                .unwrap_or_else(|poisoned| poisoned.into_inner())
6889                .contains_key(&lease.workspace_id)
6890            {
6891                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6892                    self.mark_managed_recovery_required(
6893                        &lease.lease_id,
6894                        ManagedWorktreeCleanupFailure::Backend,
6895                    );
6896                    continue;
6897                }
6898            }
6899            let snapshot = {
6900                let mut registry = self.managed_worktrees.lock()
6901                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6902                let current = registry.get_mut(&lease.lease_id)
6903                    .expect("reconciled managed lease remains present");
6904                current.expected_head = Some(listed.head.clone());
6905                current.cleanup_failure = None;
6906                current.state = if current.has_holders() {
6907                    ManagedWorktreeLeaseState::InUse
6908                } else if current.retention == ManagedWorktreeRetention::Retain {
6909                    ManagedWorktreeLeaseState::Retained
6910                } else {
6911                    ManagedWorktreeLeaseState::Ready
6912                };
6913                current.updated_at_unix_ms = unix_time_ms();
6914                current.snapshot()
6915            };
6916            if self.persist_state().is_err() {
6917                self.mark_managed_recovery_required(
6918                    &lease.lease_id,
6919                    ManagedWorktreeCleanupFailure::Backend,
6920                );
6921                continue;
6922            }
6923            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6924            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6925                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6926            }
6927        }
6928    }
6929
6930    /// Crash-recovery startup sweep: durably capture the pack for every
6931    /// `Live` managed record whose most recent session has not yet produced
6932    /// one (recovers a session whose `Exited{0}` event was observed but the
6933    /// Node crashed before the export finished). The reactive fast path for
6934    /// a single just-exited record does not go through this — see
6935    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6936    /// it can capture the exact `SessionAddress` before `publish_control`
6937    /// clears it, rather than re-deriving it from (by then likely already
6938    /// `Dormant`) record state the way a rescan naturally would.
6939    ///
6940    /// Returns as soon as every eligible record's export has been *spawned*,
6941    /// not completed — each one runs on its own detached task (see
6942    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6943    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6944    /// point this is called from `NodeServer::run()`, so nothing could ever
6945    /// drain the history round trip if this awaited it inline here either).
6946    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6947    /// is a silent skip for THIS record — an unusable source or a racing
6948    /// record is not surfaced to any caller.
6949    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6950        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6951            .session_records
6952            .lock()
6953            .unwrap_or_else(|poisoned| poisoned.into_inner())
6954            .records
6955            .values()
6956            .filter(|record| {
6957                record.state == ManagedSessionState::Live
6958                    && record.exported_context.is_none()
6959                    && record.provider_session.is_some()
6960            })
6961            .filter_map(|record| {
6962                record
6963                    .active_session
6964                    .clone()
6965                    .map(|session| (record.record_id.clone(), session))
6966            })
6967            .collect();
6968        for (record_id, session) in candidates {
6969            let shared = Arc::clone(self);
6970            tokio::spawn(async move {
6971                shared
6972                    .reconcile_context_pack_export_for_record(&record_id, session)
6973                    .await;
6974            });
6975        }
6976    }
6977
6978    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
6979    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
6980    /// round trip this performs (`discover_history`/`load_history`, via
6981    /// `export_context_pack_for_session_record`) can only ever settle once
6982    /// `NativeRuntime::tick()` runs again to drain the authority worker's
6983    /// completion — and `tick()` is exclusively owned by, and only ever
6984    /// called from, that same drive-loop task. Awaiting this inline there
6985    /// would starve `tick()` and this call could never observe its own
6986    /// command complete.
6987    ///
6988    /// Because this now genuinely races the drive loop's own
6989    /// `publish_control` (which downgrades this exact record from `Live` to
6990    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
6991    /// triggered this reconcile), the record legitimately may already be
6992    /// `Dormant` by the time this reads it, or may transition mid-flight —
6993    /// `allow_clean_detachment: true` on the downstream export call is what
6994    /// tolerates exactly that transition (and only that transition; see
6995    /// `session_record_export_target_matches`).
6996    async fn reconcile_context_pack_export_for_record(
6997        &self,
6998        record_id: &SessionRecordId,
6999        session: SessionAddress,
7000    ) {
7001        let Ok(record) = self.record(record_id) else {
7002            return;
7003        };
7004        if !matches!(
7005            record.state,
7006            ManagedSessionState::Live | ManagedSessionState::Dormant
7007        ) || record.exported_context.is_some()
7008            || record.provider_session.is_none()
7009        {
7010            return;
7011        }
7012        // Pack bytes are already durably committed inside this call, via
7013        // commit_context_pack_for_session_record's own durable
7014        // write-through (context_pack_store) — no separate step here.
7015        // Unclean exits (non-zero/forced/no loadable history) are already
7016        // rejected by context_pack_source_status_is_usable inside
7017        // materialize_context_pack: this simply returns Err and is skipped.
7018        let Ok(receipt) = self
7019            .export_context_pack_for_session_record(&record.record_id, &session, true)
7020            .await
7021        else {
7022            return;
7023        };
7024        self.set_exported_context(&record.record_id, receipt);
7025    }
7026
7027    /// Records the record's most recent clean-exit pack. No-op (not an
7028    /// error) if the record disappeared, or if it already carries an
7029    /// `exported_context` — first-write-wins, matching the harness-side
7030    /// idempotent reconciliation that consumes this field.
7031    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7032        let transaction = self
7033            .state_transaction
7034            .lock()
7035            .unwrap_or_else(|poisoned| poisoned.into_inner());
7036        let (previous, updated) = {
7037            let mut records = self
7038                .session_records
7039                .lock()
7040                .unwrap_or_else(|poisoned| poisoned.into_inner());
7041            let Some(record) = records.records.get_mut(record_id) else {
7042                return;
7043            };
7044            if record.exported_context.is_some() {
7045                return;
7046            }
7047            let previous = record.clone();
7048            record.exported_context = Some(receipt);
7049            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7050            (previous, record.clone())
7051        };
7052        if self.persist_state_locked().is_err() {
7053            self.session_records
7054                .lock()
7055                .unwrap_or_else(|poisoned| poisoned.into_inner())
7056                .records
7057                .insert(record_id.clone(), previous);
7058            return;
7059        }
7060        drop(transaction);
7061        self.publish_record(updated);
7062    }
7063
7064    /// Resolves the managed record and its exact `SessionAddress` currently
7065    /// bound to `instance_id` at exactly `generation`, used by the reactive
7066    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7067    /// record without a full rescan. Mirrors the lookup already inlined in
7068    /// `publish_control`. Captured synchronously, before `publish_control`
7069    /// for the same event clears the record's own `active_session` — the
7070    /// caller passes the resolved address through to a detached
7071    /// reconciliation task rather than re-deriving it from the record later,
7072    /// after that race has necessarily already happened.
7073    fn managed_record_export_target_for_instance(
7074        &self,
7075        instance_id: AgentInstanceId,
7076        generation: SessionGeneration,
7077    ) -> Option<(SessionRecordId, SessionAddress)> {
7078        let bindings = self
7079            .session_bindings
7080            .lock()
7081            .unwrap_or_else(|poisoned| poisoned.into_inner());
7082        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7083        let record_id = binding.record_id.clone()?;
7084        Some((
7085            record_id,
7086            SessionAddress {
7087                workspace_id: binding.workspace_id.clone(),
7088                session: SessionKey { instance_id, generation },
7089            },
7090        ))
7091    }
7092
7093    fn mark_managed_recovery_required(
7094        &self,
7095        lease_id: &ManagedWorktreeLeaseId,
7096        failure_kind: ManagedWorktreeCleanupFailure,
7097    ) {
7098        let snapshot = {
7099            let mut registry = self.managed_worktrees.lock()
7100                .unwrap_or_else(|poisoned| poisoned.into_inner());
7101            let Some(lease) = registry.get_mut(lease_id) else { return };
7102            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7103            lease.cleanup_failure = Some(failure_kind);
7104            lease.updated_at_unix_ms = unix_time_ms();
7105            lease.snapshot()
7106        };
7107        let _ = self.persist_state();
7108        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7109    }
7110
7111    fn set_persistence_error(&self, error: Option<String>) {
7112        *self
7113            .persistence_error
7114            .write()
7115            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7116            .map(|message| session_registry::sanitized_persistence_summary(&message));
7117    }
7118
7119    fn persist_state(&self) -> io::Result<()> {
7120        let _transaction = self
7121            .state_transaction
7122            .lock()
7123            .unwrap_or_else(|poisoned| poisoned.into_inner());
7124        self.persist_state_locked()
7125    }
7126
7127    fn persist_state_locked(&self) -> io::Result<()> {
7128        let workspaces = self
7129            .workspaces
7130            .read()
7131            .unwrap_or_else(|poisoned| poisoned.into_inner())
7132            .clone();
7133        let records = self
7134            .session_records
7135            .lock()
7136            .unwrap_or_else(|poisoned| poisoned.into_inner())
7137            .records
7138            .values()
7139            .cloned()
7140            .collect::<Vec<_>>();
7141        let mut managed = self.managed_worktrees
7142            .lock()
7143            .unwrap_or_else(|poisoned| poisoned.into_inner());
7144        managed.reattach_record_holders(
7145            &managed_worktree_record_holders(&records),
7146            unix_time_ms(),
7147        );
7148        let materializations = self
7149            .materializations
7150            .lock()
7151            .unwrap_or_else(|poisoned| poisoned.into_inner())
7152            .values()
7153            .cloned()
7154            .collect::<Vec<_>>();
7155        let managed_spawn_replays = self.managed_spawn_replays
7156            .lock()
7157            .unwrap_or_else(|poisoned| poisoned.into_inner())
7158            .values()
7159            .cloned()
7160            .collect::<Vec<_>>();
7161        let result = session_registry::save_v10(
7162            self.state_path.as_deref(),
7163            &self.node_id,
7164            &workspaces,
7165            &records,
7166            &managed.records(),
7167            &managed.tombstones(),
7168            &materializations,
7169            &managed_spawn_replays,
7170        );
7171        match &result {
7172            Ok(warning) => self.set_persistence_error(warning.clone()),
7173            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7174        }
7175        result.map(|_| ())
7176    }
7177
7178    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7179        let _mutation_guard = self.mutation_gate.lock().await;
7180        self.begin_shutdown_locked().await
7181    }
7182
7183    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7184        if self.shutdown.swap(true, Ordering::AcqRel) {
7185            return Ok(());
7186        }
7187        SessionLifecycleHook::on_shutdown(self);
7188        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7189        let mut first_error = None;
7190        for session in &self.handle.snapshot().sessions {
7191            let remaining = deadline.saturating_duration_since(Instant::now());
7192            if remaining.is_zero() {
7193                first_error.get_or_insert_with(|| {
7194                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7195                });
7196                break;
7197            }
7198            if let Err(error) = self
7199                .dispatch_bounded(
7200                    ControlCommand::Stop {
7201                        instance_id: session.instance_id,
7202                        force: true,
7203                    },
7204                    remaining,
7205                )
7206                .await
7207            {
7208                first_error.get_or_insert(error);
7209            }
7210        }
7211        self.shutdown_notify.notify_waiters();
7212        match first_error {
7213            Some(error) => Err(error),
7214            None => Ok(()),
7215        }
7216    }
7217
7218    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7219        let bindings = self
7220            .session_bindings
7221            .lock()
7222            .unwrap_or_else(|poisoned| poisoned.into_inner());
7223        if bindings.len() >= CONTROL_SESSIONS_MAX {
7224            Err(failure(
7225                NodeFailureCode::BackendBusy,
7226                "node session-binding capacity is full; remove an existing session first",
7227            ))
7228        } else {
7229            Ok(())
7230        }
7231    }
7232
7233    /// One-owner workspace/env collision guard: a live session on
7234    /// `workspace_id` with a different `environment_profile` (option
7235    /// equality on the resolved receipt, including Some vs None) blocks
7236    /// another spawn on that same cwd. Same profile may still share the
7237    /// workspace; distinct profiles need distinct workspace ids (managed
7238    /// worktree).
7239    fn ensure_workspace_environment_profile_compatible(
7240        &self,
7241        workspace_id: &WorkspaceId,
7242        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7243    ) -> Result<(), NodeFailure> {
7244        let bindings = self
7245            .session_bindings
7246            .lock()
7247            .unwrap_or_else(|poisoned| poisoned.into_inner());
7248        for binding in bindings.values() {
7249            if &binding.workspace_id != workspace_id {
7250                continue;
7251            }
7252            if binding.environment_profile.as_ref() != environment_profile {
7253                return Err(failure(
7254                    NodeFailureCode::WorkspaceBusy,
7255                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7256                ));
7257            }
7258        }
7259        Ok(())
7260    }
7261
7262    #[cfg(test)]
7263    fn bind_session(
7264        &self,
7265        instance_id: AgentInstanceId,
7266        workspace_id: WorkspaceId,
7267        generation: SessionGeneration,
7268    ) {
7269        self.bind_session_with_policy(
7270            &SessionAddress {
7271                workspace_id,
7272                session: SessionKey { instance_id, generation },
7273            },
7274            ProviderRuntimePolicy::raw_pty(),
7275            None,
7276        );
7277    }
7278
7279    #[cfg(test)]
7280    fn bind_session_with_policy(
7281        &self,
7282        address: &SessionAddress,
7283        runtime_policy: ProviderRuntimePolicy,
7284        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7285    ) {
7286        self.bind_session_with_materialization(
7287            address,
7288            runtime_policy,
7289            environment_profile,
7290            None,
7291            None,
7292            None,
7293        );
7294    }
7295
7296    fn bind_session_with_materialization(
7297        &self,
7298        address: &SessionAddress,
7299        runtime_policy: ProviderRuntimePolicy,
7300        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7301        bundle: Option<ResolvedBundleReceipt>,
7302        context: Option<ResolvedContextPackReceipt>,
7303        materialization_id: Option<MaterializationId>,
7304    ) {
7305        let mut bindings = self
7306            .session_bindings
7307            .lock()
7308            .unwrap_or_else(|poisoned| poisoned.into_inner());
7309        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7310        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7311        bindings.insert(address.session.instance_id, SessionBinding {
7312            workspace_id: address.workspace_id.clone(),
7313            generation: address.session.generation,
7314            runtime_policy,
7315            pending_resume: None,
7316            record_id: None,
7317            managed_worktree_lease_id: None,
7318            environment_profile,
7319            bundle,
7320            context,
7321            materialization_id,
7322        });
7323    }
7324
7325    #[cfg(test)]
7326    fn bind_managed_session(
7327        &self,
7328        address: &SessionAddress,
7329        record_id: SessionRecordId,
7330        runtime_policy: ProviderRuntimePolicy,
7331        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7332    ) {
7333        self.bind_managed_session_with_materialization(
7334            address,
7335            record_id,
7336            runtime_policy,
7337            environment_profile,
7338            None,
7339            None,
7340            None,
7341        );
7342    }
7343
7344    fn bind_managed_session_with_materialization(
7345        &self,
7346        address: &SessionAddress,
7347        record_id: SessionRecordId,
7348        runtime_policy: ProviderRuntimePolicy,
7349        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7350        bundle: Option<ResolvedBundleReceipt>,
7351        context: Option<ResolvedContextPackReceipt>,
7352        materialization_id: Option<MaterializationId>,
7353    ) {
7354        let mut bindings = self
7355            .session_bindings
7356            .lock()
7357            .unwrap_or_else(|poisoned| poisoned.into_inner());
7358        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7359        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7360        bindings.insert(
7361            address.session.instance_id,
7362            SessionBinding {
7363                workspace_id: address.workspace_id.clone(),
7364                generation: address.session.generation,
7365                runtime_policy,
7366                pending_resume: None,
7367                record_id: Some(record_id),
7368                managed_worktree_lease_id: None,
7369                environment_profile,
7370                bundle,
7371                context,
7372                materialization_id,
7373            },
7374        );
7375    }
7376
7377    #[cfg(test)]
7378    fn bind_spawn_session(
7379        &self,
7380        address: &SessionAddress,
7381        provider: AgentId,
7382        mode: SessionMode,
7383        runtime_policy: ProviderRuntimePolicy,
7384        record_policy: SpawnRecordPolicy,
7385        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7386    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7387        self.bind_spawn_session_with_materialization(
7388            address,
7389            provider,
7390            mode,
7391            runtime_policy,
7392            record_policy,
7393            environment_profile,
7394            None,
7395            None,
7396            None,
7397        )
7398    }
7399
7400    fn bind_spawn_session_with_materialization(
7401        &self,
7402        address: &SessionAddress,
7403        provider: AgentId,
7404        mode: SessionMode,
7405        runtime_policy: ProviderRuntimePolicy,
7406        record_policy: SpawnRecordPolicy,
7407        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7408        bundle: Option<ResolvedBundleReceipt>,
7409        context: Option<ResolvedContextPackReceipt>,
7410        materialization_id: Option<MaterializationId>,
7411    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7412        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7413            && !runtime_policy.provider_session_identity
7414        {
7415            self.bind_session_with_materialization(
7416                address,
7417                runtime_policy,
7418                environment_profile,
7419                bundle,
7420                context,
7421                materialization_id,
7422            );
7423            return Ok(None);
7424        }
7425        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7426        let record = self.new_record(
7427            address,
7428            provider,
7429            mode,
7430            active_record_state(runtime_policy.provider_session_identity, false),
7431            None,
7432            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7433            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7434            record_owns_lifecycle.then(|| context.clone()).flatten(),
7435        )?;
7436        let record_id = record.record_id.clone();
7437        let transaction = self
7438            .state_transaction
7439            .lock()
7440            .unwrap_or_else(|poisoned| poisoned.into_inner());
7441        self.insert_record(record.clone())?;
7442        let previous_materialization = if record_owns_lifecycle {
7443            if let Some(materialization_id) = materialization_id.as_ref() {
7444                let mut materializations = self
7445                    .materializations
7446                    .lock()
7447                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7448                let Some(ownership) = materializations.get_mut(materialization_id) else {
7449                    drop(materializations);
7450                    self.remove_record_memory(&record_id);
7451                    return Err(failure(
7452                        NodeFailureCode::BackendOperationFailed,
7453                        "session environment ownership disappeared before binding",
7454                    ));
7455                };
7456                let previous = ownership.clone();
7457                if ownership
7458                    .transfer_to_record(record_id.clone(), unix_time_ms())
7459                    .is_err()
7460                {
7461                    drop(materializations);
7462                    self.remove_record_memory(&record_id);
7463                    return Err(failure(
7464                        NodeFailureCode::BackendOperationFailed,
7465                        "session environment ownership transfer failed",
7466                    ));
7467                }
7468                Some(previous)
7469            } else {
7470                None
7471            }
7472        } else {
7473            None
7474        };
7475        self.bind_managed_session_with_materialization(
7476            address,
7477            record_id.clone(),
7478            runtime_policy,
7479            environment_profile,
7480            bundle,
7481            context,
7482            materialization_id.clone(),
7483        );
7484        if let Err(error) = self.persist_state_locked() {
7485            self.remove_binding(address);
7486            self.remove_record_memory(&record_id);
7487            if let (Some(materialization_id), Some(previous)) =
7488                (materialization_id.as_ref(), previous_materialization)
7489            {
7490                self.materializations
7491                    .lock()
7492                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7493                    .insert(materialization_id.clone(), previous);
7494            }
7495            return Err(persistence_failure(error));
7496        }
7497        drop(transaction);
7498        self.publish_record(record);
7499        Ok(Some(record_id))
7500    }
7501
7502    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7503        for _ in 0..8 {
7504            let bytes = random_nonce().map_err(|error| {
7505                failure(
7506                    NodeFailureCode::BackendOperationFailed,
7507                    &format!("session record identity generation failed: {error}"),
7508                )
7509            })?;
7510            let mut value = String::with_capacity(27);
7511            value.push_str("sr-");
7512            for byte in &bytes[..12] {
7513                use std::fmt::Write as _;
7514                let _ = write!(&mut value, "{byte:02x}");
7515            }
7516            let record_id = SessionRecordId::new(value).map_err(|error| {
7517                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7518            })?;
7519            let records = self
7520                .session_records
7521                .lock()
7522                .unwrap_or_else(|poisoned| poisoned.into_inner());
7523            if !records.records.contains_key(&record_id) {
7524                return Ok(record_id);
7525            }
7526        }
7527        Err(failure(
7528            NodeFailureCode::BackendBusy,
7529            "could not allocate a unique session record ID",
7530        ))
7531    }
7532
7533    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7534        for _ in 0..8 {
7535            let bytes = random_nonce().map_err(|error| {
7536                failure(
7537                    NodeFailureCode::BackendOperationFailed,
7538                    &format!("task identity generation failed: {error}"),
7539                )
7540            })?;
7541            let mut nonce = [0_u8; 12];
7542            nonce.copy_from_slice(&bytes[..12]);
7543            let task_id = TaskId::from_nonce(nonce);
7544            let records = self
7545                .session_records
7546                .lock()
7547                .unwrap_or_else(|poisoned| poisoned.into_inner());
7548            if !records.records.values().any(|record| {
7549                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7550                    == Some(&task_id)
7551            }) {
7552                return Ok(task_id);
7553            }
7554        }
7555        Err(failure(
7556            NodeFailureCode::BackendBusy,
7557            "could not allocate a unique task ID",
7558        ))
7559    }
7560
7561    fn default_record_name(&self, provider: &AgentId) -> String {
7562        let records = self
7563            .session_records
7564            .lock()
7565            .unwrap_or_else(|poisoned| poisoned.into_inner());
7566        let ordinal = records
7567            .records
7568            .values()
7569            .filter(|record| &record.provider == provider)
7570            .count()
7571            .saturating_add(1);
7572        format!("{} #{ordinal}", provider.as_str())
7573    }
7574
7575    fn new_record(
7576        &self,
7577        address: &SessionAddress,
7578        provider: AgentId,
7579        mode: SessionMode,
7580        state: ManagedSessionState,
7581        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7582        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7583        bundle: Option<ResolvedBundleReceipt>,
7584        context: Option<ResolvedContextPackReceipt>,
7585    ) -> Result<ManagedSessionRecord, NodeFailure> {
7586        let canonical_root = self.workspace_root(&address.workspace_id)?;
7587        let now = unix_time_ms();
7588        Ok(ManagedSessionRecord {
7589            record_id: self.allocate_record_id()?,
7590            display_name: self.default_record_name(&provider),
7591            provider,
7592            mode,
7593            state,
7594            workspace_id: address.workspace_id.clone(),
7595            canonical_root: opaque_windows_path(canonical_root),
7596            provider_session,
7597            active_session: Some(address.clone()),
7598            environment_profile,
7599            bundle,
7600            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7601            context,
7602            exported_context: None,
7603            task_binding: None,
7604            created_at_unix_ms: now,
7605            updated_at_unix_ms: now,
7606            last_error: None,
7607        })
7608    }
7609
7610    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7611        let mut records = self
7612            .session_records
7613            .lock()
7614            .unwrap_or_else(|poisoned| poisoned.into_inner());
7615        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7616            return Err(failure(
7617                NodeFailureCode::BackendBusy,
7618                "managed session record capacity is full; forget an inactive record first",
7619            ));
7620        }
7621        if let Some(identity) = record.provider_session.as_ref() {
7622            if records.records.values().any(|candidate| {
7623                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7624                    session_registry::same_provider_session(
7625                        &candidate.provider,
7626                        candidate_identity,
7627                        &record.provider,
7628                        identity,
7629                    )
7630                })
7631            }) {
7632                return Err(failure(
7633                    NodeFailureCode::SessionRecordConflict,
7634                    "provider session is already represented by another managed record",
7635                ));
7636            }
7637        }
7638        match records.records.entry(record.record_id.clone()) {
7639            std::collections::btree_map::Entry::Vacant(entry) => {
7640                entry.insert(record);
7641            }
7642            std::collections::btree_map::Entry::Occupied(_) => {
7643                return Err(failure(
7644                    NodeFailureCode::SessionRecordConflict,
7645                    "session record ID collision",
7646                ));
7647            }
7648        }
7649        Ok(())
7650    }
7651
7652    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7653        self.session_records
7654            .lock()
7655            .unwrap_or_else(|poisoned| poisoned.into_inner())
7656            .records
7657            .get(record_id)
7658            .cloned()
7659            .ok_or_else(|| {
7660                failure(
7661                    NodeFailureCode::UnknownSessionRecord,
7662                    "managed session record does not exist",
7663                )
7664            })
7665    }
7666
7667    fn revalidate_session_record_preview(
7668        &self,
7669        expected: &ManagedSessionRecord,
7670        identity: &ProviderSessionIdentity,
7671    ) -> Result<(), NodeFailure> {
7672        let current = self.record(&expected.record_id)?;
7673        let current_root = self.workspace_root(&current.workspace_id)?;
7674        if current.provider != expected.provider
7675            || current.workspace_id != expected.workspace_id
7676            || current.canonical_root != expected.canonical_root
7677            || current.provider_session.as_ref() != Some(identity)
7678            || !platform::roots_equal(
7679                &current_root,
7680                windows_path_text(&current.canonical_root),
7681            )
7682        {
7683            return Err(failure(
7684                NodeFailureCode::SessionRecordConflict,
7685                "managed session changed while its preview was loading",
7686            ));
7687        }
7688        Ok(())
7689    }
7690
7691    fn revalidate_session_record_context_export(
7692        &self,
7693        expected: &ManagedSessionRecord,
7694        identity: Option<&ProviderSessionIdentity>,
7695        session: &SessionAddress,
7696        allow_clean_detachment: bool,
7697    ) -> Result<(), NodeFailure> {
7698        let current = self.record(&expected.record_id)?;
7699        let current_root = self.workspace_root(&current.workspace_id)?;
7700        let snapshot = self.internal_session_snapshot(session)?;
7701        let binding = self
7702            .session_bindings
7703            .lock()
7704            .unwrap_or_else(|poisoned| poisoned.into_inner())
7705            .get(&session.session.instance_id)
7706            .cloned()
7707            .ok_or_else(|| {
7708                failure(
7709                    NodeFailureCode::SessionRecordConflict,
7710                    "managed session runtime binding disappeared during context export",
7711                )
7712            })?;
7713        if !session_record_context_export_binding_is_exact(
7714            expected,
7715            &current,
7716            identity,
7717            session,
7718            &binding,
7719            &snapshot.agent_id,
7720            &current_root,
7721            allow_clean_detachment,
7722        ) || !session_record_context_export_source_is_usable(
7723            &current,
7724            &snapshot.status,
7725            allow_clean_detachment,
7726        ) {
7727            return Err(failure(
7728                NodeFailureCode::SessionRecordConflict,
7729                "managed session binding changed during context export",
7730            ));
7731        }
7732        Ok(())
7733    }
7734
7735    fn revalidate_loaded_history(
7736        &self,
7737        session: &SessionAddress,
7738        candidate_id: &str,
7739        loaded: &HistorySessionRecord,
7740    ) -> Result<(), NodeFailure> {
7741        let snapshot = self.internal_session_snapshot(session)?;
7742        if snapshot.history.pending.is_some()
7743            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7744            || snapshot.history.loaded.as_ref() != Some(loaded)
7745        {
7746            return Err(failure(
7747                NodeFailureCode::SessionRecordConflict,
7748                "managed session history changed during context export",
7749            ));
7750        }
7751        Ok(())
7752    }
7753
7754    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7755        self.session_records
7756            .lock()
7757            .unwrap_or_else(|poisoned| poisoned.into_inner())
7758            .records
7759            .remove(record_id)
7760    }
7761
7762    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7763        if self.destroy_record_materialization(record_id)? {
7764            self.publish(NodeEvent::SessionRecordRemoved {
7765                record_id: record_id.clone(),
7766            });
7767            return Ok(());
7768        }
7769        let transaction = self
7770            .state_transaction
7771            .lock()
7772            .unwrap_or_else(|poisoned| poisoned.into_inner());
7773        let Some(removed) = self.remove_record_memory(record_id) else {
7774            return Ok(());
7775        };
7776        if let Err(error) = self.persist_state_locked() {
7777            let _ = self.insert_record(removed);
7778            return Err(persistence_failure(error));
7779        }
7780        drop(transaction);
7781        self.publish(NodeEvent::SessionRecordRemoved {
7782            record_id: record_id.clone(),
7783        });
7784        Ok(())
7785    }
7786
7787    fn publish_record(&self, record: ManagedSessionRecord) {
7788        self.publish(NodeEvent::SessionRecordUpserted { record });
7789    }
7790
7791    fn mark_record_error(
7792        &self,
7793        record_id: &SessionRecordId,
7794        message: &str,
7795    ) -> Result<(), NodeFailure> {
7796        let transaction = self
7797            .state_transaction
7798            .lock()
7799            .unwrap_or_else(|poisoned| poisoned.into_inner());
7800        let previous = {
7801            let mut records = self
7802                .session_records
7803                .lock()
7804                .unwrap_or_else(|poisoned| poisoned.into_inner());
7805            let Some(record) = records.records.get_mut(record_id) else {
7806                return Ok(());
7807            };
7808            let previous = record.clone();
7809            record.state = ManagedSessionState::Unavailable;
7810            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7811            record.updated_at_unix_ms = unix_time_ms();
7812            previous
7813        };
7814        if let Err(error) = self.persist_state_locked() {
7815            self.session_records
7816                .lock()
7817                .unwrap_or_else(|poisoned| poisoned.into_inner())
7818                .records
7819                .insert(record_id.clone(), previous);
7820            return Err(persistence_failure(error));
7821        }
7822        let record = self.record(record_id)?;
7823        drop(transaction);
7824        self.publish_record(record);
7825        Ok(())
7826    }
7827
7828    fn rename_session_record(
7829        &self,
7830        record_id: &SessionRecordId,
7831        display_name: String,
7832    ) -> Result<ManagedSessionRecord, NodeFailure> {
7833        validate_display_name(&display_name)
7834            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7835        let _transaction = self
7836            .state_transaction
7837            .lock()
7838            .unwrap_or_else(|poisoned| poisoned.into_inner());
7839        let previous = {
7840            let mut records = self
7841                .session_records
7842                .lock()
7843                .unwrap_or_else(|poisoned| poisoned.into_inner());
7844            let record = records.records.get_mut(record_id).ok_or_else(|| {
7845                failure(
7846                    NodeFailureCode::UnknownSessionRecord,
7847                    "managed session record does not exist",
7848                )
7849            })?;
7850            let previous = record.clone();
7851            record.display_name = display_name;
7852            record.updated_at_unix_ms = unix_time_ms();
7853            previous
7854        };
7855        if let Err(error) = self.persist_state_locked() {
7856            self.session_records
7857                .lock()
7858                .unwrap_or_else(|poisoned| poisoned.into_inner())
7859                .records
7860                .insert(record_id.clone(), previous);
7861            return Err(persistence_failure(error));
7862        }
7863        let updated = self.record(record_id)?;
7864        self.publish_record(updated.clone());
7865        Ok(updated)
7866    }
7867
7868    fn set_session_task(
7869        &self,
7870        record_id: &SessionRecordId,
7871        expected_revision: u64,
7872        target: SessionTaskTargetV1,
7873    ) -> Result<ManagedSessionRecord, NodeFailure> {
7874        let transaction = self
7875            .state_transaction
7876            .lock()
7877            .unwrap_or_else(|poisoned| poisoned.into_inner());
7878        let current = self.record(record_id)?;
7879        let current_task_id = current
7880            .task_binding
7881            .as_ref()
7882            .and_then(|binding| binding.task_id.as_ref());
7883        let current_revision = current
7884            .task_binding
7885            .as_ref()
7886            .map_or(0, |binding| binding.revision);
7887        if expected_revision != current_revision {
7888            return Err(failure(
7889                NodeFailureCode::SessionRecordConflict,
7890                "managed session task binding revision changed",
7891            ));
7892        }
7893        let exact = match &target {
7894            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7895            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7896            SessionTaskTargetV1::New => false,
7897        };
7898        if exact {
7899            return Ok(current);
7900        }
7901        let revision = current_revision.checked_add(1).ok_or_else(|| {
7902            failure(
7903                NodeFailureCode::SessionRecordConflict,
7904                "managed session task binding revision is exhausted",
7905            )
7906        })?;
7907        let task_id = match target {
7908            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7909            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7910            SessionTaskTargetV1::Clear => None,
7911        };
7912        let changed_at_unix_ms = unix_time_ms()
7913            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7914                failure(
7915                    NodeFailureCode::SessionRecordConflict,
7916                    "managed session record timestamp is exhausted",
7917                )
7918            })?);
7919        let updated = {
7920            let mut records = self
7921                .session_records
7922                .lock()
7923                .unwrap_or_else(|poisoned| poisoned.into_inner());
7924            let record = records.records.get_mut(record_id).ok_or_else(|| {
7925                failure(
7926                    NodeFailureCode::UnknownSessionRecord,
7927                    "managed session record does not exist",
7928                )
7929            })?;
7930            record.task_binding = Some(SessionTaskBindingV1 {
7931                revision,
7932                task_id,
7933                changed_at_unix_ms,
7934            });
7935            record.updated_at_unix_ms = changed_at_unix_ms;
7936            record.clone()
7937        };
7938        if let Err(error) = self.persist_state_locked() {
7939            self.session_records
7940                .lock()
7941                .unwrap_or_else(|poisoned| poisoned.into_inner())
7942                .records
7943                .insert(record_id.clone(), current);
7944            return Err(persistence_failure(error));
7945        }
7946        drop(transaction);
7947        self.publish_record(updated.clone());
7948        Ok(updated)
7949    }
7950
7951    fn index_provider_session(
7952        &self,
7953        workspace_id: WorkspaceId,
7954        provider: AgentId,
7955        identity: ProviderSessionIdentity,
7956        display_name: String,
7957    ) -> Result<ManagedSessionRecord, NodeFailure> {
7958        self.index_provider_session_with_policy(
7959            workspace_id,
7960            provider,
7961            identity,
7962            display_name,
7963            false,
7964        )
7965    }
7966
7967    fn index_provider_session_with_policy(
7968        &self,
7969        workspace_id: WorkspaceId,
7970        provider: AgentId,
7971        identity: ProviderSessionIdentity,
7972        display_name: String,
7973        allow_validated_transcript_path: bool,
7974    ) -> Result<ManagedSessionRecord, NodeFailure> {
7975        validate_display_name(&display_name)
7976            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7977        identity
7978            .validate()
7979            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7980        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7981            return Err(failure(
7982                NodeFailureCode::InvalidRequest,
7983                "provider session reference index v1 does not accept transcript paths",
7984            ));
7985        }
7986        let canonical_root = self.workspace_root(&workspace_id)?;
7987        if !self.enabled_providers.contains(&provider) {
7988            return Err(failure(
7989                NodeFailureCode::InvalidRequest,
7990                "provider is not enabled on this node",
7991            ));
7992        }
7993
7994        let transaction = self
7995            .state_transaction
7996            .lock()
7997            .unwrap_or_else(|poisoned| poisoned.into_inner());
7998        {
7999            let records = self
8000                .session_records
8001                .lock()
8002                .unwrap_or_else(|poisoned| poisoned.into_inner());
8003            if let Some(existing) = records.records.values().find(|candidate| {
8004                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8005                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8006                })
8007            }) {
8008                if existing.provider == provider && existing.workspace_id == workspace_id {
8009                    return Ok(existing.clone());
8010                }
8011                return Err(failure(
8012                    NodeFailureCode::SessionRecordConflict,
8013                    "provider session reference conflicts with another provider or workspace",
8014                ));
8015            }
8016            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8017                return Err(failure(
8018                    NodeFailureCode::BackendBusy,
8019                    "managed session record capacity is full; forget an inactive record first",
8020                ));
8021            }
8022        }
8023
8024        let now = unix_time_ms();
8025        let record = ManagedSessionRecord {
8026            record_id: self.allocate_record_id()?,
8027            display_name,
8028            provider,
8029            mode: SessionMode::Pty,
8030            state: ManagedSessionState::Dormant,
8031            workspace_id,
8032            canonical_root: opaque_windows_path(canonical_root),
8033            provider_session: Some(identity),
8034            active_session: None,
8035            environment_profile: None,
8036            bundle: None,
8037            context_id: None,
8038            context: None,
8039            exported_context: None,
8040            task_binding: None,
8041            created_at_unix_ms: now,
8042            updated_at_unix_ms: now,
8043            last_error: None,
8044        };
8045        self.insert_record(record.clone())?;
8046        if let Err(error) = self.persist_state_locked() {
8047            self.remove_record_memory(&record.record_id);
8048            return Err(persistence_failure(error));
8049        }
8050        drop(transaction);
8051        self.publish_record(record.clone());
8052        Ok(record)
8053    }
8054
8055    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8056        self.session_bindings
8057            .lock()
8058            .unwrap_or_else(|poisoned| poisoned.into_inner())
8059            .iter()
8060            .find_map(|(instance_id, binding)| {
8061                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8062                    workspace_id: binding.workspace_id.clone(),
8063                    session: SessionKey {
8064                        instance_id: *instance_id,
8065                        generation: binding.generation,
8066                    },
8067                })
8068            })
8069    }
8070
8071    async fn forget_session_record(
8072        &self,
8073        record_id: &SessionRecordId,
8074    ) -> Result<(), NodeFailure> {
8075        let record = self.record(record_id)?;
8076        if record.active_session.is_some()
8077            || matches!(
8078                record.state,
8079                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8080            )
8081        {
8082            return Err(failure(
8083                NodeFailureCode::SessionRecordBusy,
8084                "stop the managed session before forgetting it",
8085            ));
8086        }
8087        if let Some(address) = self.bound_address_for_record(record_id) {
8088            self.remove_session(&address).await?;
8089        }
8090        self.remove_managed_session_record(record_id, &record.workspace_id).await
8091    }
8092
8093    /// Removes one managed session record from durable state and notifies
8094    /// every listener -- the shared transaction body behind both
8095    /// `forget_session_record`'s operator-driven path (busy-checked above
8096    /// it, and clears any still-live session binding before calling in
8097    /// here) and the retention sweep (`retire_unavailable_session_records`,
8098    /// which only ever selects `ManagedSessionState::Unavailable` records
8099    /// that are already known never-busy and never bound, so it calls
8100    /// straight into this with neither check). Cleans a managed-worktree
8101    /// lease if the record held one, removes the record's environment
8102    /// materialization (or, absent one, removes it from memory directly),
8103    /// persists under `state_transaction` with rollback on persist failure,
8104    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8105    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8106    /// exactly as it does for an operator-driven forget.
8107    async fn remove_managed_session_record(
8108        &self,
8109        record_id: &SessionRecordId,
8110        workspace_id: &WorkspaceId,
8111    ) -> Result<(), NodeFailure> {
8112        let managed_lease_id = self.managed_worktrees.lock()
8113            .unwrap_or_else(|poisoned| poisoned.into_inner())
8114            .lease_for_workspace(workspace_id);
8115        if !self.destroy_record_materialization(record_id)? {
8116            let transaction = self
8117                .state_transaction
8118                .lock()
8119                .unwrap_or_else(|poisoned| poisoned.into_inner());
8120            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8121                failure(
8122                    NodeFailureCode::UnknownSessionRecord,
8123                    "managed session record does not exist",
8124                )
8125            })?;
8126            if let Err(error) = self.persist_state_locked() {
8127                let _ = self.insert_record(removed);
8128                return Err(persistence_failure(error));
8129            }
8130            drop(transaction);
8131        }
8132        self.publish(NodeEvent::SessionRecordRemoved {
8133            record_id: record_id.clone(),
8134        });
8135        if let Some(lease_id) = managed_lease_id {
8136            if let Some(snapshot) = self.managed_worktrees.lock()
8137                .unwrap_or_else(|poisoned| poisoned.into_inner())
8138                .get(&lease_id)
8139                .map(ManagedWorktreeLeaseRecord::snapshot)
8140            {
8141                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8142            }
8143            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8144        }
8145        Ok(())
8146    }
8147
8148    /// Retires `ManagedSessionState::Unavailable` records against the
8149    /// node's configured `session_record_retention` age/keep-N policy.
8150    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8151    /// `Dormant` is resumable and folding it into this sweep would delete
8152    /// records a caller can still act on. Both policy fields default to
8153    /// `0` (disabled); when both are `0` this is a no-op. When both are
8154    /// set, a record is retired only if it fails BOTH tests (older than
8155    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8156    /// workspace); when only one is set, that one alone decides. Called
8157    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8158    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8159    /// loop -- see both call sites.
8160    async fn retire_unavailable_session_records(&self) {
8161        let policy = self.session_record_retention;
8162        let age_enabled = policy.age_ms > 0;
8163        let keep_enabled = policy.keep_per_workspace > 0;
8164        if !age_enabled && !keep_enabled {
8165            return;
8166        }
8167        let now = unix_time_ms();
8168        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8169            self.session_records
8170                .lock()
8171                .unwrap_or_else(|poisoned| poisoned.into_inner())
8172                .records
8173                .values()
8174                .filter(|record| record.state == ManagedSessionState::Unavailable)
8175                .map(|record| {
8176                    (
8177                        record.record_id.clone(),
8178                        record.workspace_id.clone(),
8179                        record.provider.clone(),
8180                        record.updated_at_unix_ms,
8181                    )
8182                })
8183                .collect()
8184        };
8185        if candidates.is_empty() {
8186            return;
8187        }
8188        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8189            BTreeMap::new();
8190        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8191            by_workspace
8192                .entry(workspace_id)
8193                .or_default()
8194                .push((record_id, provider, updated_at_unix_ms));
8195        }
8196        let reason = if age_enabled && keep_enabled {
8197            "age+keep-n"
8198        } else if age_enabled {
8199            "age"
8200        } else {
8201            "keep-n"
8202        };
8203        let mut retired: u64 = 0;
8204        for (workspace_id, mut records) in by_workspace {
8205            // Newest `updated_at_unix_ms` first, so index `>=
8206            // keep_per_workspace` names exactly "beyond the newest N".
8207            records.sort_by(|left, right| right.2.cmp(&left.2));
8208            for (index, (record_id, provider, updated_at_unix_ms)) in
8209                records.into_iter().enumerate()
8210            {
8211                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8212                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8213                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8214                let should_retire = if age_enabled && keep_enabled {
8215                    age_hit && keep_hit
8216                } else {
8217                    age_hit || keep_hit
8218                };
8219                if !should_retire {
8220                    continue;
8221                }
8222                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8223                    Ok(()) => {
8224                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8225                        retired += 1;
8226                        tracing::info!(
8227                            node_id = %self.node_id,
8228                            record_id = %record_id,
8229                            workspace_id = %workspace_id,
8230                            provider = %provider,
8231                            age_ms = record_age_ms,
8232                            reason,
8233                            "managed session record retired"
8234                        );
8235                    }
8236                    Err(error) => tracing::warn!(
8237                        node_id = %self.node_id,
8238                        record_id = %record_id,
8239                        workspace_id = %workspace_id,
8240                        code = ?error.code,
8241                        cause = %error.message,
8242                        "session record retention sweep failed to retire a record"
8243                    ),
8244                }
8245            }
8246        }
8247        if retired > 0 {
8248            let remaining_unavailable = self
8249                .session_records
8250                .lock()
8251                .unwrap_or_else(|poisoned| poisoned.into_inner())
8252                .records
8253                .values()
8254                .filter(|record| record.state == ManagedSessionState::Unavailable)
8255                .count();
8256            tracing::info!(
8257                node_id = %self.node_id,
8258                retired,
8259                remaining_unavailable,
8260                "session record retention sweep completed"
8261            );
8262        }
8263    }
8264
8265    async fn resume_session_record(
8266        &self,
8267        record_id: &SessionRecordId,
8268        terminal_size: gate4agent_types::TerminalSize,
8269        initial_prompt: Option<String>,
8270    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8271        let record = self.record(record_id)?;
8272        if record.active_session.is_some()
8273            || matches!(
8274                record.state,
8275                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8276            )
8277        {
8278            return Err(failure(
8279                NodeFailureCode::SessionRecordBusy,
8280                "managed session already has a live runtime binding",
8281            ));
8282        }
8283        let identity = record.provider_session.clone().ok_or_else(|| {
8284            failure(
8285                NodeFailureCode::SessionRecordNotResumable,
8286                "managed session has no verified provider session identity",
8287            )
8288        })?;
8289        let working_directory = self.workspace_root(&record.workspace_id)?;
8290        if !platform::roots_equal(
8291            &working_directory,
8292            windows_path_text(&record.canonical_root),
8293        ) {
8294            return Err(failure(
8295                NodeFailureCode::SessionRecordConflict,
8296                "managed session workspace root changed; refusing to resume in another directory",
8297            ));
8298        }
8299        {
8300            let records = self
8301                .session_records
8302                .lock()
8303                .unwrap_or_else(|poisoned| poisoned.into_inner());
8304            if records.records.values().any(|candidate| {
8305                candidate.record_id != *record_id
8306                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8307                        session_registry::same_provider_session(
8308                            &candidate.provider,
8309                            candidate_identity,
8310                            &record.provider,
8311                            &identity,
8312                        )
8313                    })
8314                    && (candidate.active_session.is_some()
8315                        || matches!(
8316                            candidate.state,
8317                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8318                        ))
8319            }) {
8320                return Err(failure(
8321                    NodeFailureCode::SessionRecordConflict,
8322                    "provider session is already active through another managed record",
8323                ));
8324            }
8325        }
8326        let runtime_requirement = if initial_prompt.is_some() {
8327            ProviderRuntimeRequirement::ResumeWithPrompt
8328        } else {
8329            ProviderRuntimeRequirement::Resume
8330        };
8331        let runtime_policy = self.admit_provider_runtime(
8332            &record.provider,
8333            runtime_requirement,
8334        )
8335        .await?;
8336        let request = ResumeLaunchRequest {
8337            working_directory,
8338            terminal_size,
8339            initial_prompt,
8340        };
8341        request
8342            .validate()
8343            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8344        self.ensure_binding_capacity()?;
8345        let resolved_materialization = match self.resolve_record_materialization(
8346            record_id,
8347            &record.provider,
8348            record.mode,
8349            record.environment_profile.as_ref(),
8350            record.bundle.as_ref(),
8351            record.context.as_ref(),
8352        ) {
8353            Ok(materialization) => materialization,
8354            Err(error) => {
8355                let label = match error.code {
8356                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8357                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8358                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8359                    _ => "environment-profile-unavailable",
8360                };
8361                self.mark_record_error(record_id, label)?;
8362                return Err(error);
8363            }
8364        };
8365        let (materialization_id, environment_overlay) = match resolved_materialization {
8366            Some((id, overlay)) => (Some(id), overlay),
8367            None => (None, None),
8368        };
8369        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8370        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8371            instance_id,
8372            &record.provider,
8373            record.mode,
8374            record.environment_profile.as_ref(),
8375        ) {
8376            Ok(selection) => selection,
8377            Err(error) => {
8378                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8379                return Err(error);
8380            }
8381        };
8382        let mut instance_overlay = environment_overlay
8383            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8384            .transpose()?
8385            .flatten();
8386        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8387            self.remove_session(&stale_address).await?;
8388        }
8389        let address = SessionAddress {
8390            workspace_id: record.workspace_id.clone(),
8391            session: SessionKey {
8392                instance_id,
8393                generation: SessionGeneration(1),
8394            },
8395        };
8396        let transaction = self
8397            .state_transaction
8398            .lock()
8399            .unwrap_or_else(|poisoned| poisoned.into_inner());
8400        let current = self.record(record_id)?;
8401        if current.active_session.is_some()
8402            || matches!(
8403                current.state,
8404                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8405            )
8406        {
8407            return Err(failure(
8408                NodeFailureCode::SessionRecordBusy,
8409                "managed session became active while resume was being prepared",
8410            ));
8411        }
8412        self.bind_managed_session_with_materialization(
8413            &address,
8414            record_id.clone(),
8415            runtime_policy,
8416            record.environment_profile.clone(),
8417            record.bundle.clone(),
8418            record.context.clone(),
8419            materialization_id,
8420        );
8421        if let Some(selection) = environment_selection.take() {
8422            selection.retain();
8423        }
8424        if let Some(overlay) = instance_overlay.take() {
8425            overlay.retain();
8426        }
8427        drop(transaction);
8428
8429        let transport = match record.mode {
8430            SessionMode::Pty => TransportKind::Pty,
8431            SessionMode::Inline => TransportKind::Pipe,
8432            SessionMode::Acp => TransportKind::Acp,
8433        };
8434        let agent_id = record.provider.clone();
8435        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8436        if let Err(error) = self
8437            .dispatch_bounded(
8438                ControlCommand::Register {
8439                    instance_id,
8440                    agent_id,
8441                    transport,
8442                },
8443                dispatch_timeout,
8444            )
8445            .await
8446        {
8447            if let Some(binding) = self.remove_binding(&address) {
8448                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8449            }
8450            return Err(error);
8451        }
8452        let command = self.prepare_command(ControlCommand::Resume {
8453            instance_id,
8454            target: ResumeTarget::ProviderSession { identity },
8455            runtime_policy,
8456            request,
8457        });
8458        let started_after = self.current_sequence();
8459        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8460            self.rollback_spawn(&address, dispatch_timeout).await?;
8461            return Err(error);
8462        }
8463        if let Err(error) = self.dispatch_envelope(command) {
8464            self.clear_armed_resume(&address);
8465            self.rollback_spawn(&address, dispatch_timeout).await?;
8466            return Err(error);
8467        }
8468        match self
8469            .wait_for_record_resume(&address, started_after)
8470            .await
8471        {
8472            Ok(settled_address) => {
8473                let managed_lease_id = {
8474                    self.managed_worktrees.lock()
8475                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8476                        .lease_for_workspace(&record.workspace_id)
8477                };
8478                if let Some(lease_id) = managed_lease_id {
8479                    {
8480                        let mut bindings = self.session_bindings.lock()
8481                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8482                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8483                            .ok_or_else(|| failure(
8484                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8485                                "resumed managed session lost its exact binding",
8486                            ))?;
8487                        if binding.generation != settled_address.session.generation {
8488                            return Err(failure(
8489                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8490                                "resumed managed session generation diverged",
8491                            ));
8492                        }
8493                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8494                    }
8495                    let snapshot = self.managed_worktrees.lock()
8496                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8497                        .bind_session(
8498                            &lease_id,
8499                            ManagedWorktreeSessionHolder {
8500                                incarnation_id: self.incarnation_id,
8501                                instance_id: settled_address.session.instance_id,
8502                                generation: settled_address.session.generation,
8503                            },
8504                            Some(record_id.clone()),
8505                            unix_time_ms(),
8506                        ).map_err(|message| failure(
8507                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8508                            &message,
8509                        ))?;
8510                    if let Err(error) = self.persist_state() {
8511                        let _ = self.remove_session(&settled_address).await;
8512                        return Err(persistence_failure(error));
8513                    }
8514                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8515                }
8516                Ok((self.record(record_id)?, settled_address))
8517            }
8518            Err(error) => {
8519                if matches!(
8520                    error.code,
8521                    NodeFailureCode::SessionRecordNotResumable
8522                        | NodeFailureCode::BackendOperationFailed
8523                ) {
8524                    if let Some(bound) = self.bound_address_for_record(record_id) {
8525                        let _ = self.remove_session(&bound).await;
8526                    }
8527                }
8528                Err(error)
8529            }
8530        }
8531    }
8532
8533    async fn wait_for_record_resume(
8534        &self,
8535        initial_address: &SessionAddress,
8536        after_sequence: u64,
8537    ) -> Result<SessionAddress, NodeFailure> {
8538        let deadline = Instant::now()
8539            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8540        let mut scan_after = after_sequence;
8541        loop {
8542            let events = self
8543                .history
8544                .lock()
8545                .unwrap_or_else(|poisoned| poisoned.into_inner())
8546                .events
8547                .iter()
8548                .filter(|event| event.sequence > scan_after)
8549                .cloned()
8550                .collect::<Vec<_>>();
8551            for envelope in events {
8552                scan_after = scan_after.max(envelope.sequence);
8553                let NodeEvent::Control { address, event } = envelope.event else {
8554                    continue;
8555                };
8556                if address.session.instance_id != initial_address.session.instance_id {
8557                    continue;
8558                }
8559                match event.event {
8560                    ControlEventKind::Resumed { .. } => return Ok(address),
8561                    ControlEventKind::ResumeDenied { reason } => {
8562                        return Err(failure(
8563                            NodeFailureCode::SessionRecordNotResumable,
8564                            &reason,
8565                        ));
8566                    }
8567                    ControlEventKind::ResumeFailed { message }
8568                    | ControlEventKind::CommandRejected { message } => {
8569                        return Err(failure(
8570                            NodeFailureCode::BackendOperationFailed,
8571                            &message,
8572                        ));
8573                    }
8574                    _ => {}
8575                }
8576            }
8577            if Instant::now() >= deadline {
8578                return Err(failure(
8579                    NodeFailureCode::BackendBusy,
8580                    "managed resume did not settle before the bounded deadline",
8581                ));
8582            }
8583            sleep(Duration::from_millis(5)).await;
8584        }
8585    }
8586
8587    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8588        self.workspaces
8589            .read()
8590            .unwrap_or_else(|poisoned| poisoned.into_inner())
8591            .get(workspace_id)
8592            .cloned()
8593            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8594    }
8595
8596    fn workspace_roots(&self) -> Vec<PathBuf> {
8597        self.workspaces
8598            .read()
8599            .unwrap_or_else(|poisoned| poisoned.into_inner())
8600            .values()
8601            .map(PathBuf::from)
8602            .collect()
8603    }
8604
8605    async fn inspect_workspace(
8606        &self,
8607        workspace_id: WorkspaceId,
8608    ) -> Result<WorkspaceInspection, NodeFailure> {
8609        let _permit = self
8610            .inspection_slots
8611            .clone()
8612            .try_acquire_owned()
8613            .map_err(|_| {
8614                tracing::warn!(
8615                    workspace_id = %workspace_id,
8616                    "workspace inspection rejected: inspection capacity is busy",
8617                );
8618                failure(
8619                    NodeFailureCode::BackendBusy,
8620                    "workspace inspection capacity is busy",
8621                )
8622            })?;
8623        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8624            tracing::warn!(
8625                workspace_id = %workspace_id,
8626                code = ?error.code,
8627                "workspace inspection rejected: workspace is not registered",
8628            );
8629            error
8630        })?;
8631        let tree_root = canonical_root.clone();
8632        let started_at = Instant::now();
8633        let time_budget_ms = workspace_inspection_time_budget_ms();
8634        let entry_cap = workspace_inspection_entry_cap();
8635        let deadline = started_at + Duration::from_millis(time_budget_ms);
8636        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8637            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8638        })
8639        .await
8640        .map_err(|error| {
8641            tracing::warn!(
8642                workspace_id = %workspace_id,
8643                error = %error,
8644                "workspace inspection rejected: walk task failed",
8645            );
8646            failure(
8647                NodeFailureCode::BackendOperationFailed,
8648                &format!("workspace inspection task failed: {error}"),
8649            )
8650        })?;
8651        let (mut git, git_time_budget_exceeded) =
8652            inspect_git_workspace(&canonical_root, deadline).await;
8653        if !git.worktrees.is_empty() {
8654            let registered = self
8655                .workspaces
8656                .read()
8657                .unwrap_or_else(|poisoned| poisoned.into_inner())
8658                .iter()
8659                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8660                .collect::<Vec<_>>();
8661            for worktree in &mut git.worktrees {
8662                worktree.workspace_id = registered
8663                    .iter()
8664                    .find(|(_, root)| {
8665                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8666                    })
8667                    .map(|(workspace_id, _)| workspace_id.clone());
8668            }
8669        }
8670        if git.is_repository {
8671            git.managed_worktree = self
8672                .managed_worktrees
8673                .lock()
8674                .unwrap_or_else(|poisoned| poisoned.into_inner())
8675                .git_scope(&workspace_id, git.branch.as_deref());
8676        }
8677        let truncation = workspace_inspection_truncation(
8678            &walk_budget,
8679            git_time_budget_exceeded,
8680            started_at.elapsed(),
8681        );
8682        if let Some(truncation) = &truncation {
8683            tracing::info!(
8684                workspace_id = %workspace_id,
8685                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8686                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8687                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8688                entries_visited = truncation.entries_visited,
8689                elapsed_ms = truncation.elapsed_ms,
8690                "workspace inspection truncated by its inner budget",
8691            );
8692        }
8693        Ok(WorkspaceInspection {
8694            workspace_id,
8695            entries,
8696            tree_truncated,
8697            git,
8698            truncation,
8699        })
8700    }
8701
8702    async fn browse_host_directories(
8703        &self,
8704        directory: Option<OpaqueHostPath>,
8705        after: Option<OpaqueHostPath>,
8706    ) -> Result<HostDirectoryListing, NodeFailure> {
8707        let permit = self
8708            .inspection_slots
8709            .clone()
8710            .try_acquire_owned()
8711            .map_err(|_| {
8712                failure(
8713                    NodeFailureCode::BackendBusy,
8714                    "host directory browse capacity is busy",
8715                )
8716            })?;
8717        let task = tokio::task::spawn_blocking(move || {
8718            let _permit = permit;
8719            browse_host_directories(directory, after)
8720        });
8721        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8722            .await
8723            .map_err(|_| {
8724                failure(
8725                    NodeFailureCode::HostDirectoryReadTimedOut,
8726                    "host directory browse exceeded its bounded deadline",
8727                )
8728            })?
8729            .map_err(|_| {
8730                failure(
8731                    NodeFailureCode::HostDirectoryReadFailed,
8732                    "host directory browse task failed",
8733                )
8734            })?
8735            .map_err(host_directory_failure)
8736    }
8737
8738    async fn read_workspace_file(
8739        &self,
8740        workspace_id: WorkspaceId,
8741        path: RepositoryPath,
8742    ) -> Result<WorkspaceFileRead, NodeFailure> {
8743        let canonical_root = self.workspace_root(&workspace_id)?;
8744        let permit = self
8745            .inspection_slots
8746            .clone()
8747            .try_acquire_owned()
8748            .map_err(|_| {
8749                failure(
8750                    NodeFailureCode::BackendBusy,
8751                    "workspace file read capacity is busy",
8752                )
8753            })?;
8754        let path_to_read = path.clone();
8755        let task = tokio::task::spawn_blocking(move || {
8756            let _permit = permit;
8757            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8758        });
8759        let content = timeout(
8760            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8761            task,
8762        )
8763        .await
8764        .map_err(|_| {
8765            failure(
8766                NodeFailureCode::RepositoryFileReadTimedOut,
8767                "repository file read exceeded its bounded deadline",
8768            )
8769        })?
8770        .map_err(|_| {
8771            failure(
8772                NodeFailureCode::RepositoryFileReadFailed,
8773                "repository file read task failed",
8774            )
8775        })?
8776        .map_err(workspace_file_failure)?;
8777        let content = match content {
8778            WorkspaceFileBytes::Utf8(text) => {
8779                let revision = Some(workspace_file_revision(text.as_bytes()));
8780                return Ok(WorkspaceFileRead {
8781                    workspace_id,
8782                    path,
8783                    content: WorkspaceFileContent::Utf8 {
8784                        byte_len: u32::try_from(text.len())
8785                            .expect("bounded workspace text length must fit u32"),
8786                        text,
8787                    },
8788                    revision,
8789                });
8790            }
8791            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8792                WorkspaceFileContent::NonUtf8 {
8793                    byte_len: u32::try_from(byte_length)
8794                        .expect("bounded workspace file length must fit u32"),
8795                }
8796            }
8797            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8798                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8799            },
8800        };
8801        Ok(WorkspaceFileRead {
8802            workspace_id,
8803            path,
8804            content,
8805            revision: None,
8806        })
8807    }
8808
8809    async fn write_workspace_file(
8810        &self,
8811        workspace_id: WorkspaceId,
8812        path: RepositoryPath,
8813        expected_revision: WorkspaceFileRevision,
8814        text: String,
8815    ) -> Result<WorkspaceFileRead, NodeFailure> {
8816        let canonical_root = self.workspace_root(&workspace_id)?;
8817        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8818            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8819        })?;
8820        let path_to_write = path.clone();
8821        let expected = expected_revision.as_str().to_owned();
8822        let text_to_write = text.clone();
8823        let task = tokio::task::spawn_blocking(move || {
8824            let _permit = permit;
8825            write_workspace_file_to_disk(
8826                Path::new(&canonical_root),
8827                &path_to_write,
8828                &expected,
8829                &text_to_write,
8830            )
8831        });
8832        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8833            .await
8834            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8835            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8836            .map_err(workspace_file_write_failure)?;
8837        Ok(WorkspaceFileRead {
8838            workspace_id,
8839            path,
8840            content: WorkspaceFileContent::Utf8 {
8841                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8842                text,
8843            },
8844            revision: Some(WorkspaceFileRevision::new(revision)
8845                .expect("workspace writer returns a valid SHA-256 revision")),
8846        })
8847    }
8848
8849    async fn create_workspace_file(
8850        &self,
8851        workspace_id: WorkspaceId,
8852        path: RepositoryPath,
8853    ) -> Result<WorkspaceFileRead, NodeFailure> {
8854        let canonical_root = self.workspace_root(&workspace_id)?;
8855        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8856            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8857        })?;
8858        let path_to_create = path.clone();
8859        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8860        let task_commit_state = Arc::clone(&commit_state);
8861        let task = tokio::task::spawn_blocking(move || {
8862            let _permit = permit;
8863            create_workspace_file_on_disk(
8864                Path::new(&canonical_root),
8865                &path_to_create,
8866                &task_commit_state,
8867            )
8868        });
8869        let revision = settle_workspace_entry_create(
8870            task,
8871            commit_state,
8872            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8873            "repository file create exceeded its bounded deadline",
8874            "repository file create task failed",
8875        )
8876        .await?;
8877        Ok(WorkspaceFileRead {
8878            workspace_id,
8879            path,
8880            content: WorkspaceFileContent::Utf8 {
8881                text: String::new(),
8882                byte_len: 0,
8883            },
8884            revision: Some(WorkspaceFileRevision::new(revision)
8885                .expect("workspace creator returns a valid SHA-256 revision")),
8886        })
8887    }
8888
8889    async fn create_workspace_directory(
8890        &self,
8891        workspace_id: WorkspaceId,
8892        path: RepositoryPath,
8893    ) -> Result<WorkspaceEntry, NodeFailure> {
8894        let canonical_root = self.workspace_root(&workspace_id)?;
8895        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8896            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8897        })?;
8898        let path_to_create = path.clone();
8899        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8900        let task_commit_state = Arc::clone(&commit_state);
8901        let task = tokio::task::spawn_blocking(move || {
8902            let _permit = permit;
8903            create_workspace_directory_on_disk(
8904                Path::new(&canonical_root),
8905                &path_to_create,
8906                &task_commit_state,
8907            )
8908        });
8909        settle_workspace_entry_create(
8910            task,
8911            commit_state,
8912            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8913            "repository directory create exceeded its bounded deadline",
8914            "repository directory create task failed",
8915        )
8916        .await?;
8917        Ok(WorkspaceEntry {
8918            relative_path: path,
8919            kind: WorkspaceEntryKind::Directory,
8920        })
8921    }
8922
8923    async fn read_git_history(
8924        &self,
8925        workspace_id: WorkspaceId,
8926        path: Option<RepositoryPath>,
8927        before: Option<GitObjectId>,
8928        limit: u16,
8929    ) -> Result<GitHistoryPage, NodeFailure> {
8930        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8931            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8932        })?;
8933        let root = self.workspace_root(&workspace_id)?;
8934        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8935    }
8936
8937    async fn read_git_diff(
8938        &self,
8939        workspace_id: WorkspaceId,
8940        request: GitDiffRequest,
8941    ) -> Result<GitDiff, NodeFailure> {
8942        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8943            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8944        })?;
8945        let root = self.workspace_root(&workspace_id)?;
8946        read_git_diff_bounded(&root, request).await
8947    }
8948
8949    fn native_session_route_root(
8950        &self,
8951        route: &NativeSessionCatalogRoute,
8952    ) -> Result<Option<String>, NodeFailure> {
8953        route
8954            .validate()
8955            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8956        match route.scope {
8957            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8958                .workspace_id
8959                .as_ref()
8960                .map(|workspace_id| self.workspace_root(workspace_id))
8961                .transpose(),
8962            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8963        }
8964    }
8965
8966    fn project_native_session_entries(
8967        &self,
8968        route: &NativeSessionCatalogRoute,
8969        entries: Vec<ScopedNativeSessionCatalogEntry>,
8970    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8971        let current_root = route
8972            .workspace_id
8973            .as_ref()
8974            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8975        let records = self
8976            .session_records
8977            .lock()
8978            .unwrap_or_else(|poisoned| poisoned.into_inner());
8979        let mut seen_records = std::collections::HashSet::new();
8980        entries
8981            .into_iter()
8982            .map(|entry| {
8983                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8984                    let matches = records
8985                        .records
8986                        .values()
8987                        .filter(|record| {
8988                            record.provider == route.provider
8989                                && record.workspace_id == *workspace_id
8990                                && current_root.as_deref().is_some_and(|root| {
8991                                    platform::roots_equal(
8992                                        root,
8993                                        windows_path_text(&record.canonical_root),
8994                                    )
8995                                })
8996                                && record.provider_session.as_ref().is_some_and(|identity| {
8997                                    session_registry::same_provider_session(
8998                                        &record.provider,
8999                                        identity,
9000                                        &route.provider,
9001                                        &entry.provider_session,
9002                                    )
9003                                })
9004                        })
9005                        .map(|record| record.record_id.clone())
9006                        .collect::<Vec<_>>();
9007                    (matches.len() == 1
9008                        && seen_records.insert(matches[0].clone()))
9009                        .then(|| matches[0].clone())
9010                });
9011                let metadata = entry.metadata;
9012                crate::protocol::NativeSessionCatalogEntry {
9013                    selection_id: metadata.selection_id,
9014                    title: metadata.title,
9015                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9016                    model: metadata.model,
9017                    message_count: metadata.message_count,
9018                    completed_turn_count: metadata.completed_turn_count,
9019                    external_group: entry.external_group,
9020                    record_id,
9021                }
9022            })
9023            .collect()
9024    }
9025
9026    async fn catalog_native_sessions(
9027        &self,
9028        route: NativeSessionCatalogRoute,
9029        limit: u16,
9030    ) -> Result<(
9031        Vec<crate::protocol::NativeSessionCatalogEntry>,
9032        crate::protocol::NativeSessionCatalogSummary,
9033    ), NodeFailure> {
9034        let canonical_root = self.native_session_route_root(&route)?;
9035        let registered_workspace_roots = self.workspace_roots();
9036        let provider = route.provider.clone();
9037        let scope = route.scope;
9038        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9039            failure(
9040                NodeFailureCode::InvalidRequest,
9041                "native session catalog is not configured",
9042            )
9043        })?;
9044        let _permit = Arc::clone(&self.inspection_slots)
9045            .try_acquire_owned()
9046            .map_err(|_| {
9047                failure(
9048                    NodeFailureCode::BackendBusy,
9049                    "native session catalog capacity is busy",
9050                )
9051            })?;
9052        let page = run_native_session_catalog_operation(
9053            catalog,
9054            "native session catalog",
9055            move |catalog| {
9056                catalog.catalog_initial_for_scope(
9057                    &provider,
9058                    scope,
9059                    canonical_root.as_deref().map(Path::new),
9060                    &registered_workspace_roots,
9061                    limit,
9062                )
9063            },
9064        )
9065        .await?;
9066        let summary = crate::protocol::NativeSessionCatalogSummary {
9067            catalog_revision: page.revision,
9068            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9069            recent_total_count: page.recent_total_count,
9070            older_total_count: page.older_total_count,
9071            recent_next_after_selection_id: page.next_after_selection_id,
9072            recent_has_more: page.remaining_count > 0,
9073        };
9074        Ok((self.project_native_session_entries(&route, page.entries), summary))
9075    }
9076
9077    async fn page_native_sessions(
9078        &self,
9079        route: NativeSessionCatalogRoute,
9080        window: crate::protocol::NativeSessionCatalogWindow,
9081        catalog_revision: u64,
9082        recent_cutoff_unix_ms: u64,
9083        after_selection_id: Option<String>,
9084        limit: u16,
9085    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9086        let canonical_root = self.native_session_route_root(&route)?;
9087        let registered_workspace_roots = self.workspace_roots();
9088        let provider = route.provider.clone();
9089        let scope = route.scope;
9090        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9091            failure(
9092                NodeFailureCode::InvalidRequest,
9093                "native session catalog is not configured",
9094            )
9095        })?;
9096        let _permit = Arc::clone(&self.inspection_slots)
9097            .try_acquire_owned()
9098            .map_err(|_| {
9099                failure(
9100                    NodeFailureCode::BackendBusy,
9101                    "native session catalog capacity is busy",
9102                )
9103            })?;
9104        let page = run_native_session_catalog_operation(
9105            catalog,
9106            "native session catalog page",
9107            move |catalog| {
9108                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9109                    &provider,
9110                    scope,
9111                    canonical_root.as_deref().map(Path::new),
9112                    &registered_workspace_roots,
9113                    window,
9114                    catalog_revision,
9115                    recent_cutoff_unix_ms,
9116                    after_selection_id.as_deref(),
9117                    limit,
9118                ))
9119            },
9120        )
9121        .await?;
9122        let page = page.map_err(|error| match error {
9123            NativeSessionCatalogError::StaleCatalog => failure(
9124                NodeFailureCode::StaleNativeSessionCatalog,
9125                "native session catalog revision or cursor is stale",
9126            ),
9127            _ => failure(
9128                NodeFailureCode::BackendOperationFailed,
9129                "native session catalog page failed",
9130            ),
9131        })?;
9132        let entries = self.project_native_session_entries(&route, page.entries);
9133        Ok(crate::protocol::NativeSessionCatalogPage {
9134            window: page.window,
9135            revision: page.revision,
9136            entries,
9137            next_after_selection_id: page.next_after_selection_id,
9138            remaining_count: page.remaining_count,
9139            has_more: page.remaining_count > 0,
9140        })
9141    }
9142
9143    async fn preview_native_session(
9144        &self,
9145        selection: NativeSessionSelection,
9146        message_limit: u16,
9147    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9148        selection
9149            .validate()
9150            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9151        let canonical_root = self.native_session_route_root(&selection.route)?;
9152        let registered_workspace_roots = self.workspace_roots();
9153        let provider = selection.route.provider.clone();
9154        let scope = selection.route.scope;
9155        let catalog_revision = selection.catalog_revision;
9156        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9157        let selection_id = selection.selection_id.clone();
9158        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9159            failure(
9160                NodeFailureCode::InvalidRequest,
9161                "native session preview is not configured",
9162            )
9163        })?;
9164        let _permit = Arc::clone(&self.inspection_slots)
9165            .try_acquire_owned()
9166            .map_err(|_| {
9167                failure(
9168                    NodeFailureCode::BackendBusy,
9169                    "native session preview capacity is busy",
9170                )
9171            })?;
9172        let preview = run_native_session_catalog_operation(
9173            catalog,
9174            "native session preview",
9175            move |catalog| {
9176                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9177                    &provider,
9178                    scope,
9179                    canonical_root.as_deref().map(Path::new),
9180                    &registered_workspace_roots,
9181                    catalog_revision,
9182                    recent_cutoff_unix_ms,
9183                    &selection_id,
9184                    message_limit,
9185                ))
9186            },
9187        )
9188        .await?;
9189        preview
9190            .map(Into::into)
9191            .map_err(native_session_preview_failure)
9192    }
9193
9194    async fn index_native_session(
9195        &self,
9196        selection: NativeSessionSelection,
9197        display_name: String,
9198    ) -> Result<ManagedSessionRecord, NodeFailure> {
9199        selection
9200            .validate()
9201            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9202        if selection.route.scope
9203            != gate4agent_types::NativeSessionCatalogScope::Workspace
9204        {
9205            return Err(failure(
9206                NodeFailureCode::WorkspaceRegistrationRequired,
9207                "register the external project as a workspace before indexing or resuming it",
9208            ));
9209        }
9210        let workspace_id = selection
9211            .route
9212            .workspace_id
9213            .clone()
9214            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9215        let canonical_root = self
9216            .native_session_route_root(&selection.route)?
9217            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9218        let registered_workspace_roots = self.workspace_roots();
9219        let provider = selection.route.provider.clone();
9220        let catalog_revision = selection.catalog_revision;
9221        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9222        let selection_id = selection.selection_id.clone();
9223        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9224            failure(
9225                NodeFailureCode::InvalidRequest,
9226                "native session index is not configured",
9227            )
9228        })?;
9229        let _permit = Arc::clone(&self.inspection_slots)
9230            .try_acquire_owned()
9231            .map_err(|_| {
9232                failure(
9233                    NodeFailureCode::BackendBusy,
9234                    "native session index capacity is busy",
9235                )
9236            })?;
9237        let resolved = run_native_session_catalog_operation(
9238            catalog,
9239            "native session index resolution",
9240            move |catalog| {
9241                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9242                    &provider,
9243                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9244                    Some(Path::new(&canonical_root)),
9245                    &registered_workspace_roots,
9246                    catalog_revision,
9247                    recent_cutoff_unix_ms,
9248                    &selection_id,
9249                ))
9250            },
9251        )
9252        .await?
9253        .map_err(native_session_preview_failure)?;
9254        self.index_provider_session_with_policy(
9255            workspace_id,
9256            selection.route.provider,
9257            resolved.identity,
9258            display_name,
9259            true,
9260        )
9261    }
9262
9263    async fn preview_native_session_by_session_id(
9264        &self,
9265        workspace_id: WorkspaceId,
9266        provider: AgentId,
9267        session_id: String,
9268        message_limit: u16,
9269    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9270        let canonical_root = self.workspace_root(&workspace_id)?;
9271        let registered_workspace_roots = self.workspace_roots();
9272        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9273            failure(
9274                NodeFailureCode::InvalidRequest,
9275                "native session preview is not configured",
9276            )
9277        })?;
9278        let _permit = Arc::clone(&self.inspection_slots)
9279            .try_acquire_owned()
9280            .map_err(|_| {
9281                failure(
9282                    NodeFailureCode::BackendBusy,
9283                    "native session preview capacity is busy",
9284                )
9285            })?;
9286        run_native_session_catalog_operation(
9287            catalog,
9288            "native session preview",
9289            move |catalog| {
9290                catalog.preview_session_id_for_workspace(
9291                    &provider,
9292                    Path::new(&canonical_root),
9293                    &registered_workspace_roots,
9294                    &session_id,
9295                    message_limit,
9296                )
9297            },
9298        )
9299        .await
9300    }
9301
9302    async fn register_workspace(
9303        &self,
9304        workspace_id: WorkspaceId,
9305        root: String,
9306    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9307        validate_workspace_request_root(&root)?;
9308        let canonicalize_workspace_id = workspace_id.clone();
9309        let workspace = tokio::task::spawn_blocking(move || {
9310            WorkspaceConfig::new(canonicalize_workspace_id, root)
9311        })
9312        .await
9313        .map_err(|error| {
9314            failure(
9315                NodeFailureCode::BackendOperationFailed,
9316                &format!("workspace canonicalization task failed: {error}"),
9317            )
9318        })?
9319        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9320        let transaction = self
9321            .state_transaction
9322            .lock()
9323            .unwrap_or_else(|poisoned| poisoned.into_inner());
9324        let mut workspaces = self
9325            .workspaces
9326            .write()
9327            .unwrap_or_else(|poisoned| poisoned.into_inner());
9328        if workspaces.contains_key(&workspace_id) {
9329            return Err(failure(
9330                NodeFailureCode::DuplicateWorkspaceId,
9331                "workspace ID is already registered",
9332            ));
9333        }
9334        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9335            platform::roots_equal(existing_root, workspace.canonical_root())
9336        }) {
9337            return Err(failure(
9338                NodeFailureCode::DuplicateWorkspaceRoot,
9339                &format!("workspace root is already registered as '{existing_id}'"),
9340            ));
9341        }
9342        {
9343            let records = self
9344                .session_records
9345                .lock()
9346                .unwrap_or_else(|poisoned| poisoned.into_inner());
9347            if let Some(record) = records.records.values().find(|record| {
9348                record.workspace_id == workspace_id
9349                    && !record
9350                        .canonical_root
9351                        .as_utf8()
9352                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9353            }) {
9354                return Err(failure(
9355                    NodeFailureCode::SessionRecordConflict,
9356                    &format!(
9357                        "workspace ID is retained by session record '{}' at another root",
9358                        record.record_id,
9359                    ),
9360                ));
9361            }
9362        }
9363        let snapshot = WorkspaceSnapshot {
9364            workspace_id: workspace_id.clone(),
9365            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9366            sessions: Vec::new(),
9367            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9368            managed_worktree_profiles: Some(WorktreeProfileInventory {
9369                profiles: Vec::new(),
9370            }),
9371        };
9372        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9373        drop(workspaces);
9374        let previous_records = {
9375            let mut records = self
9376                .session_records
9377                .lock()
9378                .unwrap_or_else(|poisoned| poisoned.into_inner());
9379            let previous = records.records.clone();
9380            for record in records.records.values_mut().filter(|record| {
9381                record.workspace_id == workspace_id
9382                    && record
9383                        .canonical_root
9384                        .as_utf8()
9385                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9386            }) {
9387                if record.active_session.is_none() {
9388                    record.state = detached_record_state(record);
9389                }
9390                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9391                    record.last_error = None;
9392                }
9393                record.updated_at_unix_ms = unix_time_ms();
9394            }
9395            previous
9396        };
9397        if let Err(error) = self.persist_state_locked() {
9398            self.workspaces
9399                .write()
9400                .unwrap_or_else(|poisoned| poisoned.into_inner())
9401                .remove(&workspace_id);
9402            self.session_records
9403                .lock()
9404                .unwrap_or_else(|poisoned| poisoned.into_inner())
9405                .records = previous_records;
9406            return Err(persistence_failure(error));
9407        }
9408        let restored = self
9409            .session_records
9410            .lock()
9411            .unwrap_or_else(|poisoned| poisoned.into_inner())
9412            .records
9413            .values()
9414            .filter(|record| record.workspace_id == workspace_id)
9415            .cloned()
9416            .collect::<Vec<_>>();
9417        drop(transaction);
9418        self.publish(NodeEvent::WorkspaceAdded {
9419            workspace: snapshot.clone(),
9420        });
9421        for record in restored {
9422            self.publish_record(record);
9423        }
9424        Ok(snapshot)
9425    }
9426
9427    async fn create_standalone_workspace(
9428        &self,
9429        workspace_id: WorkspaceId,
9430        root: String,
9431        initial_branch: Option<String>,
9432    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9433        validate_workspace_request_root(&root)?;
9434        validate_git_revision("initial branch", initial_branch.as_deref())?;
9435        let preflight_workspace_id = workspace_id.clone();
9436        let preflight_root = root.clone();
9437        let canonical_candidate = tokio::task::spawn_blocking(move || {
9438            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9439        })
9440        .await
9441        .map_err(|_| failure(
9442            NodeFailureCode::BackendOperationFailed,
9443            "standalone workspace root preflight task failed",
9444        ))?
9445        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9446        {
9447            let workspaces = self
9448                .workspaces
9449                .read()
9450                .unwrap_or_else(|poisoned| poisoned.into_inner());
9451            if workspaces.contains_key(&workspace_id) {
9452                return Err(failure(
9453                    NodeFailureCode::DuplicateWorkspaceId,
9454                    "workspace ID is already registered",
9455                ));
9456            }
9457            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9458                platform::roots_equal(existing_root, &canonical_candidate)
9459            }) {
9460                return Err(failure(
9461                    NodeFailureCode::DuplicateWorkspaceRoot,
9462                    &format!("workspace root is already registered as '{existing_id}'"),
9463                ));
9464            }
9465        }
9466        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9467            .await
9468            .map_err(standalone_workspace_failure)?;
9469        prepared
9470            .verify_for_registration()
9471            .await
9472            .map_err(standalone_workspace_failure)?;
9473        match self
9474            .register_workspace(workspace_id, prepared.root().to_owned())
9475            .await
9476        {
9477            Ok(workspace) => Ok(workspace),
9478            Err(registration_error) => {
9479                if prepared.compensate_registration_failure().await {
9480                    Err(registration_error)
9481                } else {
9482                    Err(failure(
9483                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9484                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9485                    ))
9486                }
9487            }
9488        }
9489    }
9490
9491    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9492        let bindings = self
9493            .session_bindings
9494            .lock()
9495            .unwrap_or_else(|poisoned| poisoned.into_inner());
9496        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9497            return Err(failure(
9498                NodeFailureCode::WorkspaceBusy,
9499                "workspace retains a bound session; remove every session before unregistering it",
9500            ));
9501        }
9502        drop(bindings);
9503        let transaction = self
9504            .state_transaction
9505            .lock()
9506            .unwrap_or_else(|poisoned| poisoned.into_inner());
9507        let mut workspaces = self
9508            .workspaces
9509            .write()
9510            .unwrap_or_else(|poisoned| poisoned.into_inner());
9511        if !workspaces.contains_key(workspace_id) {
9512            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9513        }
9514        if workspaces.len() == 1 {
9515            return Err(failure(
9516                NodeFailureCode::LastWorkspace,
9517                "node must retain at least one workspace",
9518            ));
9519        }
9520        let removed_root = workspaces
9521            .remove(workspace_id)
9522            .expect("workspace presence was validated");
9523        drop(workspaces);
9524        let previous_records = {
9525            let mut records = self
9526                .session_records
9527                .lock()
9528                .unwrap_or_else(|poisoned| poisoned.into_inner());
9529            let previous = records.records.clone();
9530            for record in records
9531                .records
9532                .values_mut()
9533                .filter(|record| &record.workspace_id == workspace_id)
9534            {
9535                record.active_session = None;
9536                record.state = ManagedSessionState::Unavailable;
9537                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9538                record.updated_at_unix_ms = unix_time_ms();
9539            }
9540            previous
9541        };
9542        if let Err(error) = self.persist_state_locked() {
9543            self.workspaces
9544                .write()
9545                .unwrap_or_else(|poisoned| poisoned.into_inner())
9546                .insert(workspace_id.clone(), removed_root);
9547            self.session_records
9548                .lock()
9549                .unwrap_or_else(|poisoned| poisoned.into_inner())
9550                .records = previous_records;
9551            return Err(persistence_failure(error));
9552        }
9553        let unavailable = self
9554            .session_records
9555            .lock()
9556            .unwrap_or_else(|poisoned| poisoned.into_inner())
9557            .records
9558            .values()
9559            .filter(|record| &record.workspace_id == workspace_id)
9560            .cloned()
9561            .collect::<Vec<_>>();
9562        drop(transaction);
9563        self.publish(NodeEvent::WorkspaceRemoved {
9564            workspace_id: workspace_id.clone(),
9565        });
9566        for record in unavailable {
9567            self.publish_record(record);
9568        }
9569        Ok(())
9570    }
9571
9572    async fn create_worktree(
9573        &self,
9574        source_workspace_id: WorkspaceId,
9575        workspace_id: WorkspaceId,
9576        target_root: String,
9577        branch: String,
9578        base: Option<String>,
9579    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9580        self.require_worktree_service(&source_workspace_id)?;
9581        validate_workspace_request_root(&target_root)?;
9582        validate_git_revision("worktree base", base.as_deref())?;
9583        let source_root = self.workspace_root(&source_workspace_id)?;
9584        {
9585            let workspaces = self
9586                .workspaces
9587                .read()
9588                .unwrap_or_else(|poisoned| poisoned.into_inner());
9589            if workspaces.contains_key(&workspace_id) {
9590                return Err(failure(
9591                    NodeFailureCode::DuplicateWorkspaceId,
9592                    "workspace ID is already registered",
9593                ));
9594            }
9595            if let Some((existing_id, _)) = workspaces
9596                .iter()
9597                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9598            {
9599                return Err(failure(
9600                    NodeFailureCode::DuplicateWorkspaceRoot,
9601                    &format!("workspace root is already registered as '{existing_id}'"),
9602                ));
9603            }
9604        }
9605        let mut worktree = create_git_worktree(
9606            &source_root,
9607            &target_root,
9608            &branch,
9609            base.as_deref(),
9610        )
9611        .await
9612        .map_err(git_worktree_failure)?;
9613        let workspace = match self
9614            .register_workspace(workspace_id, worktree.path.clone())
9615            .await
9616        {
9617            Ok(workspace) => workspace,
9618            Err(registration_error) => {
9619                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9620                    return Err(failure(
9621                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9622                        "worktree registration failed and compensation requires recovery",
9623                    ));
9624                }
9625                return Err(failure(
9626                    registration_error.code,
9627                    &registration_error.message,
9628                ));
9629            }
9630        };
9631        worktree.workspace_id = Some(workspace.workspace_id.clone());
9632        Ok((protocol_worktree(worktree), workspace))
9633    }
9634
9635    async fn remove_worktree(
9636        &self,
9637        source_workspace_id: WorkspaceId,
9638        target_root: String,
9639    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9640        self.require_worktree_service(&source_workspace_id)?;
9641        validate_workspace_request_root(&target_root)?;
9642        let source_root = self.workspace_root(&source_workspace_id)?;
9643        let target_root = normalize_worktree_removal_target(&target_root)
9644            .map_err(git_worktree_failure)?;
9645        let target = list_git_worktrees(&source_root)
9646            .await
9647            .map_err(git_worktree_failure)?
9648            .into_iter()
9649            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9650            .ok_or_else(|| {
9651                failure(
9652                    NodeFailureCode::WorktreeProtected,
9653                    "refusing to remove a path that is not in Git's worktree listing",
9654                )
9655            })?;
9656        let registered_workspace_id = {
9657            let workspaces = self
9658                .workspaces
9659                .read()
9660                .unwrap_or_else(|poisoned| poisoned.into_inner());
9661            let matched = workspaces
9662                .iter()
9663                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9664                .map(|(workspace_id, _)| workspace_id.clone());
9665            if matched.is_some() && workspaces.len() == 1 {
9666                return Err(failure(
9667                    NodeFailureCode::LastWorkspace,
9668                    "node must retain at least one workspace",
9669                ));
9670            }
9671            matched
9672        };
9673        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9674            let bindings = self
9675                .session_bindings
9676                .lock()
9677                .unwrap_or_else(|poisoned| poisoned.into_inner());
9678            if bindings
9679                .values()
9680                .any(|binding| &binding.workspace_id == workspace_id)
9681            {
9682                return Err(failure(
9683                    NodeFailureCode::WorkspaceBusy,
9684                    "worktree workspace retains a bound session; remove every session first",
9685                ));
9686            }
9687        }
9688        remove_git_worktree(&source_root, &target.path)
9689            .await
9690            .map_err(git_worktree_failure)?;
9691        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9692            self.unregister_workspace(workspace_id)
9693                .map_err(|error| failure(error.code, &error.message))?;
9694        }
9695        Ok(registered_workspace_id)
9696    }
9697
9698    async fn cleanup_managed_worktree(
9699        &self,
9700        lease_id: &ManagedWorktreeLeaseId,
9701        explicit: bool,
9702    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9703        let lease = self.managed_worktrees.lock()
9704            .unwrap_or_else(|poisoned| poisoned.into_inner())
9705            .get(lease_id)
9706            .cloned()
9707            .ok_or_else(|| failure(
9708                NodeFailureCode::UnknownManagedWorktreeLease,
9709                "managed worktree lease does not exist",
9710            ))?;
9711        if lease.state == ManagedWorktreeLeaseState::Removed {
9712            return Ok(lease.snapshot());
9713        }
9714        if lease.has_holders() {
9715            return Err(failure(
9716                NodeFailureCode::ManagedWorktreeBusy,
9717                "managed worktree lease still has a session or durable-record holder",
9718            ));
9719        }
9720        if self
9721            .materializations
9722            .lock()
9723            .unwrap_or_else(|poisoned| poisoned.into_inner())
9724            .values()
9725            .any(|record| record.managed_lease_id() == Some(lease_id))
9726        {
9727            return Err(failure(
9728                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9729                "managed worktree cleanup is blocked by session environment ownership",
9730            ));
9731        }
9732        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9733            let snapshot = {
9734                let mut registry = self.managed_worktrees.lock()
9735                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9736                let current = registry.get_mut(lease_id)
9737                    .expect("managed worktree lease remains present");
9738                current.state = ManagedWorktreeLeaseState::Retained;
9739                current.cleanup_failure = None;
9740                current.updated_at_unix_ms = unix_time_ms();
9741                current.snapshot()
9742            };
9743            if self.persist_state().is_err() {
9744                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9745            }
9746            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9747            return Ok(snapshot);
9748        }
9749        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9750        if profile.revision() != &lease.profile_revision {
9751            self.mark_managed_recovery_required(
9752                lease_id,
9753                ManagedWorktreeCleanupFailure::OwnershipConflict,
9754            );
9755            return Err(failure(
9756                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9757                "managed worktree profile revision changed",
9758            ));
9759        }
9760        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9761            self.mark_managed_recovery_required(
9762                lease_id,
9763                ManagedWorktreeCleanupFailure::OwnershipConflict,
9764            );
9765            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9766        })?;
9767        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9768        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9769            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9770            managed_git_worktree_failure(error)
9771        })?;
9772        let target = listed.iter()
9773            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9774        if let Some(target) = target {
9775            if target.is_main || target.is_bare || !exact_owned_worktree(
9776                &lease,
9777                &target.path,
9778                target.branch.as_deref(),
9779                &target.head,
9780            ) {
9781                self.mark_managed_recovery_required(
9782                    lease_id,
9783                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9784                );
9785                return Err(failure(
9786                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9787                    "Git worktree identity no longer matches the managed lease",
9788                ));
9789            }
9790            if target.prunable {
9791                self.mark_managed_cleanup_blocked(
9792                    lease_id,
9793                    ManagedWorktreeCleanupFailure::Prunable,
9794                );
9795                return Err(failure(
9796                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9797                    "managed worktree is prunable and requires recovery",
9798                ));
9799            }
9800            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9801                let failure_kind = match error.kind {
9802                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9803                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9804                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9805                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9806                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9807                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9808                };
9809                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9810                    self.mark_managed_recovery_required(lease_id, failure_kind);
9811                } else {
9812                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9813                }
9814                return Err(managed_git_worktree_failure(error));
9815            }
9816        } else {
9817            match std::fs::symlink_metadata(&lease.target_root) {
9818                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9819                Ok(_) => {
9820                    self.mark_managed_recovery_required(
9821                        lease_id,
9822                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9823                    );
9824                    return Err(failure(
9825                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9826                        "managed target exists but is not owned by Git",
9827                    ));
9828                }
9829                Err(_) => {
9830                    self.mark_managed_cleanup_blocked(
9831                        lease_id,
9832                        ManagedWorktreeCleanupFailure::Backend,
9833                    );
9834                    return Err(failure(
9835                        NodeFailureCode::BackendOperationFailed,
9836                        "managed target identity could not be inspected",
9837                    ));
9838                }
9839            }
9840        }
9841        let registered = self.workspaces.read()
9842            .unwrap_or_else(|poisoned| poisoned.into_inner())
9843            .contains_key(&lease.workspace_id);
9844        if registered {
9845            self.unregister_workspace(&lease.workspace_id)?;
9846        }
9847        let removed = {
9848            self.managed_worktrees.lock()
9849                .unwrap_or_else(|poisoned| poisoned.into_inner())
9850                .tombstone(lease_id, unix_time_ms())
9851                .expect("managed worktree lease remains present during cleanup")
9852        };
9853        self.persist_state().map_err(persistence_failure)?;
9854        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9855        Ok(removed.snapshot())
9856    }
9857
9858    fn mark_managed_cleanup_blocked(
9859        &self,
9860        lease_id: &ManagedWorktreeLeaseId,
9861        failure_kind: ManagedWorktreeCleanupFailure,
9862    ) {
9863        let snapshot = {
9864            let mut registry = self.managed_worktrees.lock()
9865                .unwrap_or_else(|poisoned| poisoned.into_inner());
9866            let Some(lease) = registry.get_mut(lease_id) else { return };
9867            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9868            lease.cleanup_failure = Some(failure_kind);
9869            lease.updated_at_unix_ms = unix_time_ms();
9870            lease.snapshot()
9871        };
9872        let _ = self.persist_state();
9873        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9874    }
9875
9876    fn require_worktree_service(
9877        &self,
9878        source_workspace_id: &WorkspaceId,
9879    ) -> Result<(), NodeFailure> {
9880        if self.managed_worktrees.lock()
9881            .unwrap_or_else(|poisoned| poisoned.into_inner())
9882            .lease_for_workspace(source_workspace_id)
9883            .is_some()
9884        {
9885            return Err(failure(
9886                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9887                "managed worktree targets are internal-only workspaces",
9888            ));
9889        }
9890        match self
9891            .worktree_service_modes
9892            .get(source_workspace_id)
9893            .copied()
9894            .unwrap_or(WorktreeServiceMode::Manual)
9895        {
9896            WorktreeServiceMode::Manual => Ok(()),
9897            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9898                NodeFailureCode::UnsupportedSpawnCapability,
9899                "Git worktree service mode is unavailable in this node build",
9900            )),
9901        }
9902    }
9903
9904    fn managed_reservation_conflict(
9905        &self,
9906        workspace_id: Option<&WorkspaceId>,
9907        root: Option<&str>,
9908    ) -> bool {
9909        self.managed_worktrees.lock()
9910            .unwrap_or_else(|poisoned| poisoned.into_inner())
9911            .active_records()
9912            .any(|lease| {
9913                workspace_id.is_some_and(|workspace_id| {
9914                    workspace_id == &lease.source_workspace_id
9915                        || workspace_id == &lease.workspace_id
9916                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9917            })
9918    }
9919
9920    fn reject_managed_reservation(
9921        &self,
9922        workspace_id: Option<&WorkspaceId>,
9923        root: Option<&str>,
9924    ) -> Result<(), NodeFailure> {
9925        if self.managed_reservation_conflict(workspace_id, root) {
9926            Err(failure(
9927                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9928                "managed worktree ownership may only be mutated through its lease cleanup",
9929            ))
9930        } else {
9931            Ok(())
9932        }
9933    }
9934
9935    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9936        self.validate_address(address)?;
9937        self.workspace_root(&address.workspace_id)
9938    }
9939
9940    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9941    /// `require_policy`'s own doc on those two requirements), so they keep
9942    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9943    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9944    /// `false` on the `require_policy` call below for them because a PTY
9945    /// resume never needs those facts, not because this method cannot look
9946    /// them up.
9947    ///
9948    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9949    /// the SAME `binding.runtime_policy` every other requirement reads here
9950    /// (one computed policy, read by every gate). That policy is no longer
9951    /// PTY-terminal-verification-shaped for an ACP session --
9952    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9953    /// `provider_runtime::policy_for_transport` -- so consulting it for
9954    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9955    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9956    /// even though a prompt is an ACP session's only interface). Pipe keeps
9957    /// reading the same PTY-probe-derived value it always has --
9958    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9959    /// its own doc for why).
9960    ///
9961    /// What this method still adds on top, for ACP/Pipe only, is a
9962    /// turn-in-flight PRECONDITION ahead of that policy check: a session
9963    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
9964    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
9965    /// read off the same live `SessionSnapshot` state `require_acp_session`
9966    /// already reads for the ACP control verbs -- `TurnStarted` observed
9967    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
9968    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
9969    /// its fate in vendor-specific behaviour this node cannot observe (one
9970    /// agent may queue it, another drop it, another interleave it), so this
9971    /// refuses it outright rather than gambling on that -- a rule with no
9972    /// PTY analogue, which is why PTY does not take this extra precondition.
9973    fn require_session_runtime_policy(
9974        &self,
9975        address: &SessionAddress,
9976        requirement: ProviderRuntimeRequirement,
9977    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9978        let runtime_policy = {
9979            let bindings = self
9980                .session_bindings
9981                .lock()
9982                .unwrap_or_else(|poisoned| poisoned.into_inner());
9983            let binding = bindings
9984                .get(&address.session.instance_id)
9985                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9986            if binding.workspace_id != address.workspace_id {
9987                return Err(failure(
9988                    NodeFailureCode::SessionWorkspaceMismatch,
9989                    "session belongs to another workspace",
9990                ));
9991            }
9992            if binding.generation != address.session.generation {
9993                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9994            }
9995            binding.runtime_policy
9996        };
9997        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9998            let snapshot = self.internal_session_snapshot(address)?;
9999            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10000                && snapshot.provider.lead_activity != ProviderActivity::Idle
10001            {
10002                return Err(failure(
10003                    NodeFailureCode::TurnInFlight,
10004                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10005                ));
10006            }
10007        }
10008        require_policy(runtime_policy, false, false, requirement)
10009            .map(|()| runtime_policy)
10010            .map_err(|_| failure(
10011                NodeFailureCode::UnsupportedCapability,
10012                "session runtime policy does not admit this semantic operation",
10013            ))
10014    }
10015
10016    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10017        let mut bindings = self
10018            .session_bindings
10019            .lock()
10020            .unwrap_or_else(|poisoned| poisoned.into_inner());
10021        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10022            binding.workspace_id == address.workspace_id
10023                && binding.generation == address.session.generation
10024        }) {
10025            let removed = bindings.remove(&address.session.instance_id);
10026            self.clear_terminal_frame_watermark(address);
10027            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10028            return removed;
10029        }
10030        None
10031    }
10032
10033    fn cleanup_session_owned_materialization(
10034        &self,
10035        address: &SessionAddress,
10036        binding: &SessionBinding,
10037    ) -> Result<(), NodeFailure> {
10038        let Some(id) = binding.materialization_id.as_ref() else {
10039            return Ok(());
10040        };
10041        let owner = self
10042            .materializations
10043            .lock()
10044            .unwrap_or_else(|poisoned| poisoned.into_inner())
10045            .get(id)
10046            .map(|record| record.owner().clone());
10047        match owner {
10048            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10049            Some(MaterializationOwner::Session {
10050                incarnation_id,
10051                instance_id,
10052                generation,
10053            }) if incarnation_id == self.incarnation_id
10054                && instance_id == address.session.instance_id
10055                && generation == address.session.generation =>
10056            {
10057                self.cleanup_materialization(id)
10058            }
10059            Some(MaterializationOwner::Session { .. }) => Err(failure(
10060                NodeFailureCode::BackendOperationFailed,
10061                "session environment ownership requires recovery",
10062            )),
10063        }
10064    }
10065
10066    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10067        let mut watermarks = self
10068            .terminal_frame_watermarks
10069            .lock()
10070            .unwrap_or_else(|poisoned| poisoned.into_inner());
10071        if watermarks
10072            .get(&address.session.instance_id)
10073            .is_some_and(|(current, _)| current == address)
10074        {
10075            watermarks.remove(&address.session.instance_id);
10076        }
10077    }
10078
10079    #[cfg(test)]
10080    fn address_for(
10081        &self,
10082        instance_id: AgentInstanceId,
10083        generation: SessionGeneration,
10084    ) -> Option<SessionAddress> {
10085        let binding = self.session_bindings
10086            .lock()
10087            .unwrap_or_else(|poisoned| poisoned.into_inner())
10088            .get(&instance_id)
10089            .cloned()?;
10090        (binding.generation == generation).then_some(SessionAddress {
10091            workspace_id: binding.workspace_id,
10092            session: SessionKey { instance_id, generation },
10093        })
10094    }
10095
10096    fn arm_resume(
10097        &self,
10098        address: &SessionAddress,
10099        command_id: CommandId,
10100        runtime_policy: ProviderRuntimePolicy,
10101    ) -> Result<(), NodeFailure> {
10102        let mut bindings = self
10103            .session_bindings
10104            .lock()
10105            .unwrap_or_else(|poisoned| poisoned.into_inner());
10106        let binding = bindings
10107            .get_mut(&address.session.instance_id)
10108            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10109        if binding.workspace_id != address.workspace_id {
10110            return Err(failure(
10111                NodeFailureCode::SessionWorkspaceMismatch,
10112                "session belongs to another workspace",
10113            ));
10114        }
10115        if binding.generation != address.session.generation {
10116            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10117        }
10118        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10119        Ok(())
10120    }
10121
10122    fn clear_armed_resume(&self, address: &SessionAddress) {
10123        let mut bindings = self
10124            .session_bindings
10125            .lock()
10126            .unwrap_or_else(|poisoned| poisoned.into_inner());
10127        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10128            if binding.workspace_id == address.workspace_id
10129                && binding.generation == address.session.generation
10130            {
10131                binding.pending_resume = None;
10132            }
10133        }
10134    }
10135
10136    fn publish_control(&self, event: ControlEvent) {
10137        let revokes_harness_mcp = matches!(
10138            &event.event,
10139            ControlEventKind::Exited { .. }
10140                | ControlEventKind::Failed { .. }
10141                | ControlEventKind::Removed
10142        );
10143        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10144            let mut bindings = self
10145                .session_bindings
10146                .lock()
10147                .unwrap_or_else(|poisoned| poisoned.into_inner());
10148            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10149                return;
10150            };
10151            let previous_address = SessionAddress {
10152                workspace_id: binding.workspace_id.clone(),
10153                session: SessionKey {
10154                    instance_id: event.instance_id,
10155                    generation: binding.generation,
10156                },
10157            };
10158            let mut resume_command_rejected = false;
10159            if binding.generation == event.generation {
10160                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10161                    let rejected_resume = matches!(
10162                        &event.event,
10163                        ControlEventKind::CommandRejected { .. }
10164                    ) && event.command_id == Some(pending_command_id);
10165                    let resume_failed = matches!(
10166                        &event.event,
10167                        ControlEventKind::ResumeDenied { .. }
10168                            | ControlEventKind::ResumeFailed { .. }
10169                    );
10170                    if pending_generation == binding.generation
10171                        && (rejected_resume || resume_failed)
10172                    {
10173                        resume_command_rejected = rejected_resume;
10174                        binding.pending_resume = None;
10175                    }
10176                }
10177            } else {
10178                let expected = binding.generation.0.checked_add(1);
10179                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10180                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10181                    generation == binding.generation
10182                })
10183                    || expected != Some(event.generation.0)
10184                    || !authorized
10185                {
10186                    return;
10187                }
10188                let (_, _, runtime_policy) = binding
10189                    .pending_resume
10190                    .expect("authorized resume retains its pending runtime policy");
10191                binding.generation = event.generation;
10192                binding.runtime_policy = runtime_policy;
10193                binding.pending_resume = None;
10194            }
10195            (
10196                SessionAddress {
10197                    workspace_id: binding.workspace_id.clone(),
10198                    session: SessionKey {
10199                        instance_id: event.instance_id,
10200                        generation: event.generation,
10201                    },
10202                },
10203                (previous_address.session.generation != event.generation)
10204                    .then_some(previous_address),
10205                binding.record_id.clone(),
10206                binding.runtime_policy.provider_session_identity,
10207                resume_command_rejected,
10208            )
10209        };
10210        if let Some(previous_address) = previous_address.as_ref() {
10211            self.clear_terminal_frame_watermark(previous_address);
10212        }
10213        if let Some(record_id) = record_id {
10214            self.reconcile_managed_record(
10215                &record_id,
10216                &address,
10217                &event,
10218                identity_admitted,
10219                resume_command_rejected,
10220            );
10221        }
10222        // Every state change a session's process lifecycle can reach gets a
10223        // line here: this is the single chokepoint every `ControlEventKind`
10224        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10225        // came from a fresh spawn or a resume. Per-message provider chatter
10226        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10227        // matched here -- that is exactly the per-frame noise this crate
10228        // family's level discipline forbids at INFO.
10229        match &event.event {
10230            ControlEventKind::Running { process_id } => {
10231                tracing::info!(
10232                    node_id = %self.node_id,
10233                    workspace_id = %address.workspace_id,
10234                    session = ?address.session,
10235                    pid = ?process_id,
10236                    "session process is running",
10237                );
10238            }
10239            ControlEventKind::Exited { exit_code, forced } => {
10240                tracing::info!(
10241                    node_id = %self.node_id,
10242                    workspace_id = %address.workspace_id,
10243                    session = ?address.session,
10244                    exit_code = ?exit_code,
10245                    forced = forced,
10246                    "session process exited",
10247                );
10248            }
10249            ControlEventKind::Failed { message } => {
10250                tracing::warn!(
10251                    node_id = %self.node_id,
10252                    workspace_id = %address.workspace_id,
10253                    session = ?address.session,
10254                    cause = %message,
10255                    "session failed",
10256                );
10257            }
10258            ControlEventKind::CommandRejected { message } => {
10259                tracing::warn!(
10260                    node_id = %self.node_id,
10261                    workspace_id = %address.workspace_id,
10262                    session = ?address.session,
10263                    cause = %message,
10264                    "session command rejected",
10265                );
10266            }
10267            ControlEventKind::Removed => {
10268                tracing::info!(
10269                    node_id = %self.node_id,
10270                    workspace_id = %address.workspace_id,
10271                    session = ?address.session,
10272                    "session removed",
10273                );
10274            }
10275            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10276                tracing::info!(
10277                    node_id = %self.node_id,
10278                    workspace_id = %address.workspace_id,
10279                    session = ?address.session,
10280                    provider_session = ?provider_session,
10281                    "session resume authorized",
10282                );
10283            }
10284            ControlEventKind::Resumed { session: provider_session, process_id } => {
10285                tracing::info!(
10286                    node_id = %self.node_id,
10287                    workspace_id = %address.workspace_id,
10288                    session = ?address.session,
10289                    provider_session = ?provider_session,
10290                    pid = ?process_id,
10291                    "session resumed",
10292                );
10293            }
10294            ControlEventKind::ResumeDenied { reason } => {
10295                tracing::warn!(
10296                    node_id = %self.node_id,
10297                    workspace_id = %address.workspace_id,
10298                    session = ?address.session,
10299                    cause = %reason,
10300                    "session resume denied",
10301                );
10302            }
10303            ControlEventKind::ResumeFailed { message } => {
10304                tracing::warn!(
10305                    node_id = %self.node_id,
10306                    workspace_id = %address.workspace_id,
10307                    session = ?address.session,
10308                    cause = %message,
10309                    "session resume failed",
10310                );
10311            }
10312            _ => {}
10313        }
10314        let stream_chunk = agent_stream_chunk(&event);
10315        self.publish(NodeEvent::Control {
10316            address: address.clone(),
10317            event,
10318        });
10319        if let Some(chunk) = stream_chunk {
10320            self.publish_agent_stream_chunk(address.clone(), chunk);
10321        }
10322        if revokes_harness_mcp {
10323            SessionLifecycleHook::on_session_ended(self, &address);
10324        }
10325    }
10326
10327    fn reconcile_managed_record(
10328        &self,
10329        record_id: &SessionRecordId,
10330        address: &SessionAddress,
10331        event: &ControlEvent,
10332        identity_admitted: bool,
10333        resume_command_rejected: bool,
10334    ) {
10335        let _transaction = self
10336            .state_transaction
10337            .lock()
10338            .unwrap_or_else(|poisoned| poisoned.into_inner());
10339        let observed_identity = match &event.event {
10340            ControlEventKind::ProviderEvent {
10341                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10342                ..
10343            } if identity_admitted => Some(identity.clone()),
10344            _ => None,
10345        };
10346        let identity_was_observed = observed_identity.is_some();
10347        let replacement_id = observed_identity
10348            .as_ref()
10349            .and_then(|_| self.allocate_record_id().ok());
10350        let mut upserts = Vec::new();
10351        let mut removals = Vec::new();
10352        let mut rebind = None;
10353        let original_records;
10354        let original_materializations = self
10355            .materializations
10356            .lock()
10357            .unwrap_or_else(|poisoned| poisoned.into_inner())
10358            .clone();
10359        {
10360            let mut records = self
10361                .session_records
10362                .lock()
10363                .unwrap_or_else(|poisoned| poisoned.into_inner());
10364            original_records = records.records.clone();
10365            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10366                return;
10367            };
10368            if let Some(identity) = observed_identity {
10369                let existing_id = records
10370                    .records
10371                    .values()
10372                    .find(|record| {
10373                        record.record_id != *record_id
10374                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10375                                session_registry::same_provider_session(
10376                                    &record.provider,
10377                                    record_identity,
10378                                    &current_snapshot.provider,
10379                                    &identity,
10380                                )
10381                            })
10382                    })
10383                    .map(|record| record.record_id.clone());
10384                if let Some(existing_id) = existing_id {
10385                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10386                        record.mode == current_snapshot.mode
10387                            && record.workspace_id == current_snapshot.workspace_id
10388                            && record.environment_profile == current_snapshot.environment_profile
10389                            && record.bundle == current_snapshot.bundle
10390                            && record.context_id == current_snapshot.context_id
10391                            && record.context == current_snapshot.context
10392                            && record
10393                                .canonical_root
10394                                .as_utf8()
10395                                .zip(current_snapshot.canonical_root.as_utf8())
10396                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10397                    });
10398                    if !scope_matches {
10399                        if let Some(current) = records.records.get_mut(record_id) {
10400                            current.state = ManagedSessionState::Unavailable;
10401                            current.last_error =
10402                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10403                            current.updated_at_unix_ms = unix_time_ms();
10404                            upserts.push(current.clone());
10405                        }
10406                    } else {
10407                        let existing_is_active = records
10408                            .records
10409                            .get(&existing_id)
10410                            .is_some_and(|record| record.active_session.is_some());
10411                        if existing_is_active {
10412                            if let Some(current) = records.records.get_mut(record_id) {
10413                                current.state = ManagedSessionState::Unavailable;
10414                                current.last_error =
10415                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10416                                current.updated_at_unix_ms = unix_time_ms();
10417                                upserts.push(current.clone());
10418                            }
10419                        } else {
10420                            if current_snapshot.provider_session.is_some() {
10421                                if let Some(current) = records.records.get_mut(record_id) {
10422                                    current.active_session = None;
10423                                    current.state = ManagedSessionState::Dormant;
10424                                    current.updated_at_unix_ms = unix_time_ms();
10425                                    upserts.push(current.clone());
10426                                }
10427                            } else {
10428                                records.records.remove(record_id);
10429                                removals.push(record_id.clone());
10430                            }
10431                            if let Some(existing) = records.records.get_mut(&existing_id) {
10432                                existing.provider_session = Some(identity.clone());
10433                                existing.active_session = Some(address.clone());
10434                                existing.state = ManagedSessionState::Live;
10435                                existing.updated_at_unix_ms = unix_time_ms();
10436                                existing.last_error = None;
10437                                upserts.push(existing.clone());
10438                            }
10439                            rebind = Some(existing_id);
10440                        }
10441                    }
10442                } else if current_snapshot
10443                    .provider_session
10444                    .as_ref()
10445                    .is_some_and(|current| {
10446                        !session_registry::same_provider_session(
10447                            &current_snapshot.provider,
10448                            current,
10449                            &current_snapshot.provider,
10450                            &identity,
10451                        )
10452                    })
10453                {
10454                    if let Some(current) = records.records.get_mut(record_id) {
10455                        current.active_session = None;
10456                        if current.bundle.is_some() || current.context.is_some() {
10457                            let had_bundle = current.bundle.is_some();
10458                            current.provider_session = None;
10459                            current.bundle = None;
10460                            current.context_id = None;
10461                            current.context = None;
10462                            current.state = ManagedSessionState::Unavailable;
10463                            current.last_error = Some(if had_bundle {
10464                                "bundle-unavailable".to_owned()
10465                            } else {
10466                                "context-unavailable".to_owned()
10467                            });
10468                        } else {
10469                            current.state = ManagedSessionState::Dormant;
10470                        }
10471                        current.updated_at_unix_ms = unix_time_ms();
10472                        upserts.push(current.clone());
10473                    }
10474                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10475                        if let Some(current) = records.records.get_mut(record_id) {
10476                            current.state = ManagedSessionState::Unavailable;
10477                            current.last_error =
10478                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10479                            current.updated_at_unix_ms = unix_time_ms();
10480                            upserts.push(current.clone());
10481                        }
10482                    } else if let Some(new_record_id) = replacement_id {
10483                        let now = unix_time_ms();
10484                        let replacement = ManagedSessionRecord {
10485                            record_id: new_record_id.clone(),
10486                            display_name: format!(
10487                                "{} #{}",
10488                                current_snapshot.provider.as_str(),
10489                                records
10490                                    .records
10491                                    .values()
10492                                    .filter(|record| {
10493                                        record.provider == current_snapshot.provider
10494                                    })
10495                                    .count()
10496                                    .saturating_add(1),
10497                            ),
10498                            provider: current_snapshot.provider.clone(),
10499                            mode: current_snapshot.mode,
10500                            state: ManagedSessionState::Live,
10501                            workspace_id: current_snapshot.workspace_id,
10502                            canonical_root: current_snapshot.canonical_root,
10503                            provider_session: Some(identity),
10504                            active_session: Some(address.clone()),
10505                            environment_profile: current_snapshot.environment_profile,
10506                            bundle: current_snapshot.bundle,
10507                            context_id: current_snapshot.context_id,
10508                            context: current_snapshot.context,
10509                            // This is a fresh record_id representing the session under its
10510                            // newly-observed identity; it has not yet produced any clean
10511                            // exit of its own, so it starts with no exported pack (the old
10512                            // record, still addressable under its own record_id, keeps
10513                            // whatever exported_context it already had).
10514                            exported_context: None,
10515                            task_binding: current_snapshot.task_binding,
10516                            created_at_unix_ms: now,
10517                            updated_at_unix_ms: now,
10518                            last_error: None,
10519                        };
10520                        records
10521                            .records
10522                            .insert(new_record_id.clone(), replacement.clone());
10523                        upserts.push(replacement);
10524                        rebind = Some(new_record_id);
10525                    } else if let Some(current) = records.records.get_mut(record_id) {
10526                        current.state = ManagedSessionState::Unavailable;
10527                        current.last_error =
10528                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10529                        upserts.push(current.clone());
10530                    }
10531                } else if let Some(current) = records.records.get_mut(record_id) {
10532                    current.provider_session = Some(identity);
10533                    current.active_session = Some(address.clone());
10534                    current.state = ManagedSessionState::Live;
10535                    current.updated_at_unix_ms = unix_time_ms();
10536                    current.last_error = None;
10537                    upserts.push(current.clone());
10538                }
10539            } else if let Some(current) = records.records.get_mut(record_id) {
10540                if resume_command_rejected {
10541                    current.active_session = None;
10542                    current.state = detached_record_state(current);
10543                    current.updated_at_unix_ms = unix_time_ms();
10544                    current.last_error =
10545                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10546                    upserts.push(current.clone());
10547                } else {
10548                    match &event.event {
10549                    ControlEventKind::Running { .. }
10550                    | ControlEventKind::ResumeAuthorized { .. }
10551                    | ControlEventKind::Resumed { .. } => {
10552                        current.active_session = Some(address.clone());
10553                        current.state = active_record_state(
10554                            identity_admitted,
10555                            current.provider_session.is_some(),
10556                        );
10557                        current.updated_at_unix_ms = unix_time_ms();
10558                        current.last_error = None;
10559                        upserts.push(current.clone());
10560                    }
10561                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10562                        current.active_session = None;
10563                        current.state = detached_record_state(current);
10564                        current.updated_at_unix_ms = unix_time_ms();
10565                        upserts.push(current.clone());
10566                    }
10567                    ControlEventKind::Failed { message }
10568                    | ControlEventKind::ResumeFailed { message } => {
10569                        current.active_session = None;
10570                        current.state = detached_record_state(current);
10571                        current.updated_at_unix_ms = unix_time_ms();
10572                        current.last_error = Some(
10573                            session_registry::sanitized_record_error_summary(message),
10574                        );
10575                        upserts.push(current.clone());
10576                    }
10577                    ControlEventKind::ResumeDenied { reason } => {
10578                        current.active_session = None;
10579                        current.state = detached_record_state(current);
10580                        current.updated_at_unix_ms = unix_time_ms();
10581                        current.last_error = Some(
10582                            session_registry::sanitized_record_error_summary(reason),
10583                        );
10584                        upserts.push(current.clone());
10585                    }
10586                        _ => {}
10587                    }
10588                }
10589            }
10590        }
10591        let materialization_owner_record = identity_was_observed.then(|| {
10592            rebind.clone().unwrap_or_else(|| record_id.clone())
10593        }).and_then(|candidate| {
10594            self.session_records
10595                .lock()
10596                .unwrap_or_else(|poisoned| poisoned.into_inner())
10597                .records
10598                .get(&candidate)
10599                .filter(|record| {
10600                    record.provider_session.is_some()
10601                        && record.active_session.as_ref() == Some(address)
10602                })
10603                .map(|_| candidate)
10604        });
10605        if let Some(owner_record_id) = materialization_owner_record {
10606            let materialization_id = self
10607                .session_bindings
10608                .lock()
10609                .unwrap_or_else(|poisoned| poisoned.into_inner())
10610                .get(&address.session.instance_id)
10611                .and_then(|binding| binding.materialization_id.clone());
10612            if let Some(materialization_id) = materialization_id {
10613                let mut transfer_failed = false;
10614                {
10615                    let mut materializations = self
10616                        .materializations
10617                        .lock()
10618                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10619                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10620                        match ownership.owner() {
10621                            MaterializationOwner::Session {
10622                                incarnation_id,
10623                                instance_id,
10624                                generation,
10625                            } if *incarnation_id == self.incarnation_id
10626                                && *instance_id == address.session.instance_id
10627                                && *generation == address.session.generation =>
10628                            {
10629                                transfer_failed = ownership
10630                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10631                                    .is_err();
10632                            }
10633                            MaterializationOwner::Record { record_id }
10634                                if record_id == &owner_record_id => {}
10635                            MaterializationOwner::Record { record_id } => {
10636                                let expected = record_id.clone();
10637                                transfer_failed = ownership
10638                                    .transfer_record_owner(
10639                                        &expected,
10640                                        owner_record_id.clone(),
10641                                        unix_time_ms(),
10642                                    )
10643                                    .is_err();
10644                            }
10645                            _ => transfer_failed = true,
10646                        }
10647                        if transfer_failed {
10648                            let _ = ownership.mark_recovery_required(unix_time_ms());
10649                        }
10650                    } else {
10651                        transfer_failed = true;
10652                    }
10653                }
10654                if transfer_failed {
10655                    if let Some(record) = self
10656                        .session_records
10657                        .lock()
10658                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10659                        .records
10660                        .get_mut(&owner_record_id)
10661                    {
10662                        record.state = ManagedSessionState::Unavailable;
10663                        record.last_error = Some("environment-profile-unavailable".to_owned());
10664                        record.updated_at_unix_ms = unix_time_ms();
10665                        upserts.push(record.clone());
10666                    }
10667                }
10668            }
10669        }
10670        if let Some(new_record_id) = rebind.clone() {
10671            if let Some(binding) = self
10672                .session_bindings
10673                .lock()
10674                .unwrap_or_else(|poisoned| poisoned.into_inner())
10675                .get_mut(&address.session.instance_id)
10676            {
10677                binding.record_id = Some(new_record_id);
10678            }
10679        }
10680        if upserts.is_empty() && removals.is_empty() {
10681            return;
10682        }
10683        if self.persist_state_locked().is_err() {
10684            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10685            let mut records = self
10686                .session_records
10687                .lock()
10688                .unwrap_or_else(|poisoned| poisoned.into_inner());
10689            records.records = original_records;
10690            *self
10691                .materializations
10692                .lock()
10693                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10694            if let Some(current) = records.records.get_mut(record_id) {
10695                current.state = ManagedSessionState::Unavailable;
10696                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10697                current.updated_at_unix_ms = unix_time_ms();
10698                upserts = vec![current.clone()];
10699            } else {
10700                upserts.clear();
10701            }
10702            removals.clear();
10703            drop(records);
10704            if let Some(binding) = self
10705                .session_bindings
10706                .lock()
10707                .unwrap_or_else(|poisoned| poisoned.into_inner())
10708                .get_mut(&address.session.instance_id)
10709            {
10710                binding.record_id = Some(record_id.clone());
10711            }
10712            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10713        }
10714        for record in upserts {
10715            self.publish_record(record);
10716        }
10717        for record_id in removals {
10718            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10719        }
10720    }
10721
10722    fn snapshot(&self) -> NodeSnapshot {
10723        let control = self.handle.snapshot();
10724        let bindings = self
10725            .session_bindings
10726            .lock()
10727            .unwrap_or_else(|poisoned| poisoned.into_inner())
10728            .clone();
10729        let workspace_roots = self
10730            .workspaces
10731            .read()
10732            .unwrap_or_else(|poisoned| poisoned.into_inner())
10733            .clone();
10734        let environment_profiles = self
10735            .environment_profiles
10736            .read()
10737            .unwrap_or_else(|poisoned| poisoned.into_inner());
10738        let mut workspaces = workspace_roots
10739            .iter()
10740            .map(|(workspace_id, canonical_root)| {
10741                (
10742                    workspace_id.clone(),
10743                    WorkspaceSnapshot {
10744                        workspace_id: workspace_id.clone(),
10745                        canonical_root: opaque_windows_path(canonical_root.clone()),
10746                        sessions: Vec::new(),
10747                        worktree_service_mode: Some(
10748                            self.worktree_service_modes
10749                                .get(workspace_id)
10750                                .copied()
10751                                .unwrap_or(WorktreeServiceMode::Manual),
10752                        ),
10753                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10754                            profiles: self
10755                                .managed_worktree_profiles
10756                                .get(workspace_id)
10757                                .into_iter()
10758                                .flat_map(BTreeMap::values)
10759                                .map(|profile| ManagedWorktreeProfileSummary {
10760                                    id: profile.profile_id().clone(),
10761                                    revision: profile.revision().clone(),
10762                                    retention: profile.retention(),
10763                                })
10764                                .collect(),
10765                        }),
10766                    },
10767                )
10768            })
10769            .collect::<BTreeMap<_, _>>();
10770        let mut agent_progress = Vec::new();
10771        for session in &control.sessions {
10772            let Some(binding) = bindings.get(&session.instance_id) else {
10773                continue;
10774            };
10775            if binding.generation != session.generation {
10776                continue;
10777            }
10778            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10779                let address = SessionAddress {
10780                    workspace_id: binding.workspace_id.clone(),
10781                    session: SessionKey {
10782                        instance_id: session.instance_id,
10783                        generation: session.generation,
10784                    },
10785                };
10786                if let Some(progress) =
10787                    agent_progress_from_provider_snapshot(address, &session.provider)
10788                {
10789                    agent_progress.push(progress);
10790                }
10791                workspace.sessions.push(session.clone());
10792            }
10793        }
10794        NodeSnapshot {
10795            node_id: self.node_id.clone(),
10796            enabled_providers: self.enabled_providers.clone(),
10797            provider_runtime_statuses: {
10798                let updates = self
10799                    .provider_runtime_status_updates
10800                    .read()
10801                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10802                ProviderRuntimeStatuses::new(
10803                    self.provider_runtime_statuses
10804                        .iter()
10805                        .filter(|status| !updates.contains_key(status.provider()))
10806                        .cloned()
10807                        .chain(updates.values().cloned()),
10808                )
10809                .expect("refreshed provider runtime inventory remains bounded and unique")
10810            },
10811            workspaces: workspaces.into_values().collect(),
10812            session_records: self
10813                .session_records
10814                .lock()
10815                .unwrap_or_else(|poisoned| poisoned.into_inner())
10816                .records
10817                .values()
10818                .cloned()
10819                .collect(),
10820            managed_worktrees: self.managed_worktrees
10821                .lock()
10822                .unwrap_or_else(|poisoned| poisoned.into_inner())
10823                .snapshots(),
10824            launch_inventory: Some(LaunchInventory {
10825                spawn_profiles: Some(
10826                    self.spawn_profiles
10827                        .iter()
10828                        .filter_map(|profile| {
10829                            resolved_spawn_profile_summary(profile, &environment_profiles)
10830                        })
10831                        .collect(),
10832                ),
10833                bundles: Some(
10834                    self.bundle_catalog
10835                        .read()
10836                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10837                        .iter()
10838                        .map(NodeBundle::receipt)
10839                        .collect(),
10840                ),
10841                // Opaque station allowlist ids from empty-default catalog /
10842                // optional startup loader. Never secrets. Dig2 probe stays stub.
10843                network_allowlists: {
10844                    let catalog = self
10845                        .network_allowlist_catalog
10846                        .read()
10847                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10848                    if catalog.is_empty() {
10849                        None
10850                    } else {
10851                        Some(catalog.ids().cloned().collect())
10852                    }
10853                },
10854            }),
10855            agent_progress,
10856        }
10857    }
10858
10859    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10860        assert!(
10861            !matches!(
10862                &event,
10863                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10864            ),
10865            "terminal frame and agent stream events must use their own dedicated live channel",
10866        );
10867        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10868        let sequence = history
10869            .last_sequence
10870            .checked_add(1)
10871            .expect("node event sequence exhausted");
10872        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10873            if let Some(evicted) = history.events.pop_front() {
10874                history.replay_floor_sequence = evicted
10875                    .sequence
10876                    .checked_add(1)
10877                    .expect("node replay floor sequence exhausted");
10878                history.removed_record_providers.remove(&evicted.sequence);
10879            }
10880        }
10881        match &event {
10882            NodeEvent::HarnessMcpReadCall { .. } => {
10883                panic!("transient harness MCP calls must not enter durable event history")
10884            }
10885            NodeEvent::SessionRecordUpserted { record } => {
10886                history
10887                    .record_providers
10888                    .insert(record.record_id.clone(), record.provider.clone());
10889            }
10890            NodeEvent::SessionRecordRemoved { record_id } => {
10891                if let Some(provider) = history.record_providers.remove(record_id) {
10892                    history.removed_record_providers.insert(sequence, provider);
10893                }
10894            }
10895            NodeEvent::ManagedWorktreeUpserted { .. }
10896            | NodeEvent::ManagedWorktreeRemoved { .. }
10897            | NodeEvent::Control { .. }
10898            | NodeEvent::ControllerChanged { .. }
10899            | NodeEvent::SessionRecordHistorySummarized { .. }
10900            | NodeEvent::WorkspaceAdded { .. }
10901            | NodeEvent::WorkspaceRemoved { .. }
10902            | NodeEvent::ResyncRequired { .. }
10903            | NodeEvent::AgentStream { .. }
10904            | NodeEvent::TerminalFrame { .. } => {}
10905        }
10906        let envelope = NodeEventEnvelope { sequence, event };
10907        history.events.push_back(envelope.clone());
10908        history.last_sequence = sequence;
10909        let _ = self.event_tx.send(envelope.clone());
10910        drop(history);
10911        envelope
10912    }
10913
10914    /// Publishes a transient (never durable) `NodeEvent` -- today only
10915    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10916    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10917    /// why this must never ride the shared `event_tx` bus). Returns whether
10918    /// the send reached at least one subscriber: `broadcast::Sender::send`
10919    /// fails in exactly one way, no active receiver at that instant, and
10920    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10921    /// return to fail the pending call immediately rather than leave it to
10922    /// expire silently at `LOCAL_READ_TIMEOUT`/
10923    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10924    fn publish_transient(&self, event: NodeEvent) -> bool {
10925        assert!(event.requires_harness_mcp_proxy_capability());
10926        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10927        let envelope = NodeEventEnvelope { sequence: 0, event };
10928        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10929            Ok(_) => true,
10930            Err(broadcast::error::SendError(undelivered)) => {
10931                if let Some(NodeEventEnvelope {
10932                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10933                    ..
10934                }) = undelivered.first() {
10935                    // `request.content_type` only, never a per-tool label --
10936                    // this crate does not decode `request.body` and does not
10937                    // hold `hatchery-harness-api` (Nested Control Plane
10938                    // doctrine, Law 3; see this crate's dependency on
10939                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10940                    tracing::warn!(
10941                        call_id = call_id.as_str(),
10942                        content_type = ?request.content_type,
10943                        cause = "no-subscribers",
10944                        "harness MCP proxy event could not be published",
10945                    );
10946                }
10947                false
10948            }
10949        }
10950    }
10951
10952    fn publish_terminal_frames(&self) {
10953        let snapshot = self.handle.snapshot();
10954        let bindings = self
10955            .session_bindings
10956            .lock()
10957            .unwrap_or_else(|poisoned| poisoned.into_inner());
10958        let candidates = snapshot
10959            .sessions
10960            .iter()
10961            .filter_map(|session| {
10962                let binding = bindings.get(&session.instance_id)?;
10963                if binding.generation != session.generation {
10964                    return None;
10965                }
10966                let frame = session.terminal_frame.clone()?;
10967                Some((
10968                    SessionAddress {
10969                        workspace_id: binding.workspace_id.clone(),
10970                        session: SessionKey {
10971                            instance_id: session.instance_id,
10972                            generation: session.generation,
10973                        },
10974                    },
10975                    frame,
10976                ))
10977            })
10978            .collect();
10979        drop(bindings);
10980        self.publish_terminal_frame_candidates(candidates);
10981    }
10982
10983    fn publish_terminal_frame_candidates(
10984        &self,
10985        candidates: Vec<(SessionAddress, TerminalFrame)>,
10986    ) {
10987        let mut watermarks = self
10988            .terminal_frame_watermarks
10989            .lock()
10990            .unwrap_or_else(|poisoned| poisoned.into_inner());
10991        let mut events = Vec::with_capacity(candidates.len());
10992        for (address, frame) in candidates {
10993            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10994            let advanced = watermarks
10995                .get(&address.session.instance_id)
10996                .map_or(true, |(current_address, sequence)| {
10997                    current_address != &address || frame.sequence > *sequence
10998                });
10999            if !advanced {
11000                continue;
11001            }
11002            if is_first_frame {
11003                // This is the first PTY output this node has observed for
11004                // this session -- a one-time event per spawn, not per-frame
11005                // chatter (the watermark makes sure of that). A session that
11006                // reaches this line with no controller attached anywhere on
11007                // the node is running unobserved: exactly how processes from
11008                // repeated spawn attempts went orphaned for hours without a
11009                // single line about it.
11010                if self.controller_state().is_none() {
11011                    tracing::warn!(
11012                        node_id = %self.node_id,
11013                        workspace_id = %address.workspace_id,
11014                        session = ?address.session,
11015                        terminal_size = ?frame.size,
11016                        "session producing output with no controller attached",
11017                    );
11018                } else {
11019                    tracing::info!(
11020                        node_id = %self.node_id,
11021                        workspace_id = %address.workspace_id,
11022                        session = ?address.session,
11023                        terminal_size = ?frame.size,
11024                        "session produced its first output",
11025                    );
11026                }
11027            }
11028            watermarks.insert(
11029                address.session.instance_id,
11030                (address.clone(), frame.sequence),
11031            );
11032            if let Some((at, reported)) = self
11033                .pty_input_marks
11034                .lock()
11035                .unwrap_or_else(|poisoned| poisoned.into_inner())
11036                .get_mut(&address.session.instance_id)
11037            {
11038                // First frame after an input: PTY write -> output published.
11039                if !*reported && at.elapsed() < Duration::from_secs(5) {
11040                    *reported = true;
11041                    tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11042                }
11043            }
11044            events.push(NodeEvent::TerminalFrame { address, frame });
11045        }
11046        drop(watermarks);
11047        if events.is_empty() {
11048            return;
11049        }
11050        let mut history = self
11051            .history
11052            .lock()
11053            .unwrap_or_else(|poisoned| poisoned.into_inner());
11054        let mut envelopes = Vec::with_capacity(events.len());
11055        for event in events {
11056            let sequence = history
11057                .last_sequence
11058                .checked_add(1)
11059                .expect("node event sequence exhausted");
11060            history.last_sequence = sequence;
11061            envelopes.push(NodeEventEnvelope { sequence, event });
11062        }
11063        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11064        drop(history);
11065    }
11066
11067    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11068    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11069    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11070    /// counter every other event kind shares, never a counter of its own --
11071    /// `queue_connection_event`/`queue_connection_event_batch` compare
11072    /// `envelope.sequence` against one `discard_events_through` watermark
11073    /// regardless of which broadcast channel it arrived on, so a
11074    /// per-channel counter would desynchronize that comparison.
11075    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11076        let mut history = self
11077            .history
11078            .lock()
11079            .unwrap_or_else(|poisoned| poisoned.into_inner());
11080        let sequence = history
11081            .last_sequence
11082            .checked_add(1)
11083            .expect("node event sequence exhausted");
11084        history.last_sequence = sequence;
11085        drop(history);
11086        // Debug, not info/warn -- this fires on every single chunk, but it
11087        // is exactly the counter the next drop-measurement pass needs: what
11088        // the node actually emitted (`source_sequence`, `kind`) versus what
11089        // a harness subscriber's own forwarder later logs having received
11090        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11091        // event"), so a gap between the two logs names itself instead of
11092        // needing a new counter added ad hoc.
11093        tracing::debug!(
11094            source_sequence = chunk.source_sequence,
11095            kind = agent_stream_chunk_kind_label(&chunk.kind),
11096            envelope_sequence = sequence,
11097            "publishing agent stream chunk",
11098        );
11099        let envelope = NodeEventEnvelope {
11100            sequence,
11101            event: NodeEvent::AgentStream { address, chunk },
11102        };
11103        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11104    }
11105
11106    fn current_sequence(&self) -> u64 {
11107        self.history
11108            .lock()
11109            .unwrap_or_else(|poisoned| poisoned.into_inner())
11110            .last_sequence
11111    }
11112
11113    fn controller_state(&self) -> Option<ControllerState> {
11114        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11115        let lease = controller.as_ref().copied()?;
11116        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11117        if remaining.is_zero() {
11118            *controller = None;
11119            return None;
11120        }
11121        Some(ControllerState {
11122            connection_id: lease.connection_id,
11123            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11124        })
11125    }
11126
11127    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11128        if role != ClientRole::Operator {
11129            tracing::warn!(
11130                node_id = %self.node_id,
11131                connection_id,
11132                "controller lease rejected: observer connections cannot acquire control",
11133            );
11134            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11135        }
11136        let lease_ms = if lease_ms == 0 {
11137            DEFAULT_CONTROLLER_LEASE_MS
11138        } else {
11139            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11140        };
11141        let now = Instant::now();
11142        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11143        if let Some(active) = controller.as_ref() {
11144            if active.connection_id != connection_id && active.expires_at > now {
11145                tracing::warn!(
11146                    node_id = %self.node_id,
11147                    connection_id,
11148                    holder_connection_id = active.connection_id,
11149                    "controller lease rejected: another operator holds it",
11150                );
11151                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11152            }
11153        }
11154        // A reconnect (this connection already holds the lease, renewing
11155        // before expiry) happens on a periodic cadence; logging it at INFO
11156        // every renewal would be exactly the heartbeat chatter this crate
11157        // family's level discipline forbids. A genuine attach -- the
11158        // controller identity actually changing -- is a state change and
11159        // gets INFO; a same-connection renewal gets DEBUG.
11160        let is_new_attach = match controller.as_ref() {
11161            Some(active) => active.connection_id != connection_id,
11162            None => true,
11163        };
11164        *controller = Some(ControllerLease {
11165            connection_id,
11166            expires_at: now + Duration::from_millis(lease_ms),
11167        });
11168        drop(controller);
11169        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11170        if is_new_attach {
11171            tracing::info!(
11172                node_id = %self.node_id,
11173                connection_id,
11174                lease_ms,
11175                "controller lease acquired",
11176            );
11177        } else {
11178            tracing::debug!(
11179                node_id = %self.node_id,
11180                connection_id,
11181                lease_ms,
11182                "controller lease renewed",
11183            );
11184        }
11185        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11186        Ok(state)
11187    }
11188
11189    fn release_controller(&self, connection_id: u64) -> bool {
11190        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11191        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11192        if released {
11193            *controller = None;
11194        }
11195        drop(controller);
11196        if released {
11197            tracing::info!(
11198                node_id = %self.node_id,
11199                connection_id,
11200                "controller lease released",
11201            );
11202            self.publish(NodeEvent::ControllerChanged { controller: None });
11203        }
11204        released
11205    }
11206
11207    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11208        if role != ClientRole::Operator {
11209            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11210        }
11211        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11212        let now = Instant::now();
11213        match controller.as_ref().copied() {
11214            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11215            Some(active) if active.expires_at <= now => {
11216                *controller = None;
11217                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11218            }
11219            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11220        }
11221    }
11222
11223    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11224        if !self
11225            .workspaces
11226            .read()
11227            .unwrap_or_else(|poisoned| poisoned.into_inner())
11228            .contains_key(&address.workspace_id)
11229        {
11230            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11231        }
11232        let binding = self
11233            .session_bindings
11234            .lock()
11235            .unwrap_or_else(|poisoned| poisoned.into_inner())
11236            .get(&address.session.instance_id)
11237            .cloned()
11238            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11239        if binding.workspace_id != address.workspace_id {
11240            return Err(failure(
11241                NodeFailureCode::SessionWorkspaceMismatch,
11242                "session belongs to another workspace",
11243            ));
11244        }
11245        if binding.generation != address.session.generation {
11246            return Err(failure(
11247                NodeFailureCode::StaleGeneration,
11248                &format!(
11249                    "stale session generation: expected {}, received {}",
11250                    binding.generation.0,
11251                    address.session.generation.0,
11252                ),
11253            ));
11254        }
11255        let snapshot = self.handle.snapshot();
11256        let Some(current) = snapshot
11257            .sessions
11258            .iter()
11259            .find(|item| item.instance_id == address.session.instance_id)
11260        else {
11261            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11262        };
11263        if current.generation != address.session.generation {
11264            return Err(failure(
11265                NodeFailureCode::StaleGeneration,
11266                &format!(
11267                    "stale session generation: expected {}, received {}",
11268                    current.generation.0,
11269                    address.session.generation.0,
11270                ),
11271            ));
11272        }
11273        Ok(current.agent_id.clone())
11274    }
11275
11276    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11277        self.dispatch_envelope(self.prepare_command(command))
11278    }
11279
11280    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11281        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11282        CommandEnvelope {
11283            id: CommandId(id),
11284            command,
11285        }
11286    }
11287
11288    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11289        let command_id = envelope.id;
11290        self.handle
11291            .dispatch(envelope)
11292            .map_err(|error| match error {
11293                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11294                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11295            })?;
11296        Ok(command_id)
11297    }
11298
11299    /// Records the kernel's own reason for asynchronously refusing
11300    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11301    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11302    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11303    /// first, and if the map is still at capacity after that, the single
11304    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11305    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11306    /// is always the oldest.
11307    fn record_rejected_command(
11308        &self,
11309        command_id: CommandId,
11310        message: String,
11311        unsupported_transport: bool,
11312    ) {
11313        let now = Instant::now();
11314        let mut reasons = self.rejected_command_reasons.lock()
11315            .unwrap_or_else(|poisoned| poisoned.into_inner());
11316        reasons.retain(|_, reason| reason.expires_at > now);
11317        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11318            if let Some(&oldest) = reasons.keys().next() {
11319                reasons.remove(&oldest);
11320            }
11321        }
11322        reasons.insert(
11323            command_id,
11324            RejectedCommandReason {
11325                message,
11326                unsupported_transport,
11327                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11328            },
11329        );
11330    }
11331
11332    /// Removes and returns the kernel's reason for refusing `command_id`,
11333    /// if the drive loop has recorded one and it has not expired -- see
11334    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11335    /// wait loop calls this instead of blindly polling out its whole
11336    /// deadline when the command it is waiting on turns out to have been
11337    /// refused rather than merely slow.
11338    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11339        let now = Instant::now();
11340        let mut reasons = self.rejected_command_reasons.lock()
11341            .unwrap_or_else(|poisoned| poisoned.into_inner());
11342        match reasons.remove(&command_id) {
11343            Some(reason) if reason.expires_at > now => Some(reason),
11344            _ => None,
11345        }
11346    }
11347
11348    async fn dispatch_bounded(
11349        &self,
11350        command: ControlCommand,
11351        timeout_duration: Duration,
11352    ) -> Result<CommandId, NodeFailure> {
11353        let deadline = Instant::now() + timeout_duration;
11354        loop {
11355            match self.dispatch(command.clone()) {
11356                Ok(command_id) => return Ok(command_id),
11357                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11358                    sleep(Duration::from_millis(2)).await;
11359                }
11360                Err(error) => return Err(error),
11361            }
11362        }
11363    }
11364
11365    fn internal_session_snapshot(
11366        &self,
11367        address: &SessionAddress,
11368    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11369        self.validate_address(address)?;
11370        self.handle
11371            .snapshot()
11372            .sessions
11373            .clone()
11374            .into_iter()
11375            .find(|session| {
11376                session.instance_id == address.session.instance_id
11377                    && session.generation == address.session.generation
11378            })
11379            .ok_or_else(|| {
11380                failure(
11381                    NodeFailureCode::UnknownSession,
11382                    "session snapshot is unavailable",
11383                )
11384            })
11385    }
11386
11387    /// Confirms `address` names a live session spawned over the ACP
11388    /// transport and returns its current snapshot -- the gate every
11389    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11390    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11391    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11392    /// the session's own recorded `TransportKind` does, and that lives on
11393    /// the live snapshot, not the binding.
11394    fn require_acp_session(
11395        &self,
11396        address: &SessionAddress,
11397    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11398        let snapshot = self.internal_session_snapshot(address)?;
11399        if snapshot.transport != TransportKind::Acp {
11400            return Err(failure(
11401                NodeFailureCode::UnsupportedTransport,
11402                "this verb requires a session spawned over the ACP transport",
11403            ));
11404        }
11405        Ok(snapshot)
11406    }
11407
11408    async fn dispatch_history_bounded(
11409        &self,
11410        address: &SessionAddress,
11411        command: ControlCommand,
11412        operation: HistoryOperation,
11413    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11414        self.validate_address(address)?;
11415        let started_after = self.current_sequence();
11416        let command_id = self
11417            .dispatch_bounded(
11418                command,
11419                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11420            )
11421            .await?;
11422        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11423        let mut scan_after = started_after;
11424        let mut accepted_after = None;
11425        loop {
11426            let events = self
11427                .history
11428                .lock()
11429                .unwrap_or_else(|poisoned| poisoned.into_inner())
11430                .events
11431                .iter()
11432                .filter(|envelope| envelope.sequence > scan_after)
11433                .cloned()
11434                .collect::<Vec<_>>();
11435            for envelope in events {
11436                scan_after = scan_after.max(envelope.sequence);
11437                let NodeEvent::Control {
11438                    address: event_address,
11439                    event,
11440                } = envelope.event
11441                else {
11442                    continue;
11443                };
11444                if event_address != *address {
11445                    continue;
11446                }
11447                if event.command_id == Some(command_id) {
11448                    match &event.event {
11449                        ControlEventKind::CommandRejected { .. } => {
11450                            return Err(failure(
11451                                NodeFailureCode::InvalidRequest,
11452                                "history request was rejected",
11453                            ));
11454                        }
11455                        ControlEventKind::HistoryRequested {
11456                            operation: requested,
11457                            ..
11458                        } if requested == &operation => {
11459                            accepted_after = Some(envelope.sequence);
11460                            continue;
11461                        }
11462                        _ => {}
11463                    }
11464                }
11465                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11466                    match (&operation, &event.event) {
11467                        (
11468                            HistoryOperation::Discover { .. },
11469                            ControlEventKind::HistoryDiscovered { .. },
11470                        )
11471                        | (
11472                            HistoryOperation::Load { .. },
11473                            ControlEventKind::HistoryLoaded { .. },
11474                        ) => return self.internal_session_snapshot(address),
11475                        (_, ControlEventKind::HistoryFailed { .. }) => {
11476                            return Err(failure(
11477                                NodeFailureCode::BackendOperationFailed,
11478                                "history operation failed",
11479                            ));
11480                        }
11481                        _ => {}
11482                    }
11483                }
11484            }
11485            if Instant::now() >= deadline {
11486                return Err(failure(
11487                    NodeFailureCode::BackendBusy,
11488                    if accepted_after.is_some() {
11489                        "history operation did not settle before the bounded deadline"
11490                    } else {
11491                        "history operation was not accepted before the bounded deadline"
11492                    },
11493                ));
11494            }
11495            sleep(Duration::from_millis(2)).await;
11496        }
11497    }
11498
11499    async fn discover_history(
11500        &self,
11501        address: &SessionAddress,
11502        limit: u16,
11503    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11504        let query = HistoryQuery {
11505            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11506            limit,
11507        };
11508        query
11509            .validate()
11510            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11511        let snapshot = self
11512            .dispatch_history_bounded(
11513                address,
11514                ControlCommand::DiscoverHistory {
11515                    instance_id: address.session.instance_id,
11516                    query: query.clone(),
11517                },
11518                HistoryOperation::Discover { query },
11519            )
11520            .await?;
11521        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11522            return Err(failure(
11523                NodeFailureCode::BackendOperationFailed,
11524                "history discovery settled into an invalid state",
11525            ));
11526        }
11527        Ok(snapshot.history.candidates)
11528    }
11529
11530    async fn load_history(
11531        &self,
11532        address: &SessionAddress,
11533        candidate_id: String,
11534    ) -> Result<HistorySessionRecord, NodeFailure> {
11535        validate_candidate_id(&candidate_id)
11536            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11537        let snapshot = self
11538            .dispatch_history_bounded(
11539                address,
11540                ControlCommand::LoadHistory {
11541                    instance_id: address.session.instance_id,
11542                    candidate_id: candidate_id.clone(),
11543                },
11544                HistoryOperation::Load {
11545                    candidate_id: candidate_id.clone(),
11546                },
11547            )
11548            .await?;
11549        if snapshot.history.pending.is_some()
11550            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11551        {
11552            return Err(failure(
11553                NodeFailureCode::BackendOperationFailed,
11554                "history load settled into an invalid state",
11555            ));
11556        }
11557        snapshot.history.loaded.ok_or_else(|| {
11558            failure(
11559                NodeFailureCode::BackendOperationFailed,
11560                "history load produced no bounded session",
11561            )
11562        })
11563    }
11564
11565    /// `allow_clean_detachment`: `false` for the on-demand
11566    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11567    /// managed record must stay untouched, still live, for the whole call);
11568    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11569    /// reacts to the very clean-exit event that detaches the record out from
11570    /// under this same call — see `session_record_export_target_matches`.
11571    async fn export_context_pack_for_session_record(
11572        &self,
11573        record_id: &SessionRecordId,
11574        session: &SessionAddress,
11575        allow_clean_detachment: bool,
11576    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11577        // C2 replaces a relayed failure's prose with a canned per-code
11578        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11579        // distinct refusals are indistinguishable to anyone downstream --
11580        // and a refusal here expires a continuation, which keeps no field
11581        // for why. The prose stays on the node, where it was written.
11582        let outcome = self
11583            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11584            .await;
11585        if let Err(failure) = &outcome {
11586            tracing::warn!(
11587                record = %record_id,
11588                code = ?failure.code,
11589                reason = %failure.message,
11590                "session-record context-pack export refused",
11591            );
11592        }
11593        outcome
11594    }
11595
11596    async fn export_context_pack_for_session_record_inner(
11597        &self,
11598        record_id: &SessionRecordId,
11599        session: &SessionAddress,
11600        allow_clean_detachment: bool,
11601    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11602        // Several steps here refuse with the same BackendOperationFailed
11603        // code and no prose of their own, so which step failed is
11604        // otherwise unrecoverable -- and a refusal here expires a
11605        // continuation.
11606        let step = |name: &'static str| move |failure: NodeFailure| {
11607            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11608            failure
11609        };
11610        let record = self.record(record_id).map_err(step("record-lookup"))?;
11611        // `provider_session` is `Some` only for a provider whose resolved
11612        // runtime policy admits live identity verification (raw PTY plus a
11613        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11614        // The PTY-sidecar-observation shape (no fleet provider today, but
11615        // capability-keyed rather than vendor-locked) hardcodes
11616        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11617        // so its records go straight to `Live` with `provider_session: None`
11618        // and stay that way for the record's entire life -- that is this
11619        // shape's permanent resting state, not a race with observation, and
11620        // `discover_history`/`load_history` below do not need an identity to
11621        // do their job for it. `IdentityPending` is the actual race (an
11622        // identity-admitted provider whose observation has not landed yet):
11623        // it keeps refusing here rather than falling back, because that
11624        // provider's own history the fallback would otherwise trust is not
11625        // guaranteed to be exact without the identity it is still waiting on.
11626        let identity = match record.provider_session.clone() {
11627            Some(identity) => {
11628                if identity.key != ProviderSessionKey::SessionId {
11629                    return Err(step("provider-identity-key")(failure(
11630                        NodeFailureCode::BackendOperationFailed,
11631                        "managed session provider identity cannot bind history exactly",
11632                    )));
11633                }
11634                Some(identity)
11635            }
11636            None if record.state == ManagedSessionState::IdentityPending => {
11637                return Err(step("provider-session-identity")(failure(
11638                    NodeFailureCode::BackendOperationFailed,
11639                    "managed session has not yet observed its provider session identity",
11640                )));
11641            }
11642            None => None,
11643        };
11644        self.revalidate_session_record_context_export(
11645            &record,
11646            identity.as_ref(),
11647            session,
11648            allow_clean_detachment,
11649        ).map_err(step("revalidate-before-discovery"))?;
11650        let candidates = self
11651            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11652            .await
11653            .map_err(step("discover-history"))?;
11654        // With no verified identity to filter by, the discovered set must
11655        // still resolve to exactly one candidate: the same ambiguity refusal
11656        // an identity filter would otherwise produce is the only safety
11657        // property left available for this shape.
11658        let candidate_id = select_history_candidate(
11659            &candidates,
11660            identity.as_ref().map(|identity| identity.id.as_str()),
11661        ).map_err(step("unique-candidate"))?;
11662        let loaded = self.load_history(session, candidate_id.clone()).await
11663            .map_err(step("load-history"))?;
11664        if let Some(identity) = identity.as_ref() {
11665            if loaded.session_id != identity.id {
11666                return Err(failure(
11667                    NodeFailureCode::SessionRecordConflict,
11668                    "loaded history identity does not match the managed session record",
11669                ));
11670            }
11671        }
11672        self.revalidate_session_record_context_export(
11673            &record,
11674            identity.as_ref(),
11675            session,
11676            allow_clean_detachment,
11677        )?;
11678        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11679            .map_err(step("revalidate-loaded-history"))?;
11680        let pack = self.materialize_context_pack(session).await
11681            .map_err(step("materialize-context-pack"))?;
11682        self.commit_context_pack_for_session_record(
11683            &record,
11684            identity.as_ref(),
11685            session,
11686            &candidate_id,
11687            &loaded,
11688            pack,
11689            allow_clean_detachment,
11690        )
11691    }
11692
11693    async fn export_context_pack(
11694        &self,
11695        address: &SessionAddress,
11696    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11697        let pack = self.materialize_context_pack(address).await?;
11698        self.commit_context_pack(pack)
11699    }
11700
11701    async fn materialize_context_pack(
11702        &self,
11703        address: &SessionAddress,
11704    ) -> Result<NodeContextPack, NodeFailure> {
11705        let snapshot = self.internal_session_snapshot(address)?;
11706        if !context_pack_source_status_is_usable(&snapshot.status) {
11707            return Err(failure(
11708                NodeFailureCode::InvalidRequest,
11709                "context export requires a running or successfully exited source session",
11710            ));
11711        }
11712        if snapshot.history.pending.is_some()
11713            || snapshot.history.loaded_candidate_id.is_none()
11714        {
11715            return Err(failure(
11716                NodeFailureCode::InvalidRequest,
11717                "context export requires settled loaded history",
11718            ));
11719        }
11720        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11721            failure(
11722                NodeFailureCode::InvalidRequest,
11723                "context export requires settled loaded history",
11724            )
11725        })?;
11726        let source_provider = snapshot.agent_id.clone();
11727        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11728        let repository = self
11729            .context_pack_repository(&address.workspace_id)
11730            .await?;
11731        let current = self.internal_session_snapshot(address)?;
11732        if !context_pack_source_status_is_usable(&current.status)
11733            || current.agent_id != source_provider
11734            || current.history.pending.is_some()
11735            || current.history.loaded_candidate_id != loaded_candidate_id
11736            || current.history.loaded.as_ref() != Some(&loaded)
11737        {
11738            return Err(failure(
11739                NodeFailureCode::ContextPackMaterializationFailed,
11740                "context source changed during bounded repository capture",
11741            ));
11742        }
11743        NodeContextPack::export_with_repository(
11744            ContextPackLineageReceipt {
11745                source_node_id: self.node_id.clone(),
11746                source_session: address.clone(),
11747                source_provider,
11748            },
11749            &loaded,
11750            Some(repository),
11751        )
11752        .map_err(|_| {
11753            failure(
11754                NodeFailureCode::ContextPackMaterializationFailed,
11755                "bounded context pack export failed",
11756            )
11757        })
11758    }
11759
11760    /// Write-through to the durable [`ContextPackStore`], if one is
11761    /// configured (`state_path` is `Some`). A node with no durable state
11762    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11763    /// open at all and this is a no-op, matching `persist_state`'s own
11764    /// "durability optional, degrade gracefully when unconfigured" idiom.
11765    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11766        let mut store = self
11767            .context_pack_store
11768            .lock()
11769            .unwrap_or_else(|poisoned| poisoned.into_inner());
11770        let Some(store) = store.as_mut() else {
11771            return Ok(());
11772        };
11773        store.commit(pack).map_err(|_| {
11774            failure(
11775                NodeFailureCode::ContextPackMaterializationFailed,
11776                "durable context pack commit failed",
11777            )
11778        })
11779    }
11780
11781    fn commit_context_pack(
11782        &self,
11783        pack: NodeContextPack,
11784    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11785        self.commit_context_pack_durably(&pack)?;
11786        self.context_catalog
11787            .write()
11788            .unwrap_or_else(|poisoned| poisoned.into_inner())
11789            .insert(pack)
11790            .map_err(|_| {
11791                failure(
11792                    NodeFailureCode::ContextPackMaterializationFailed,
11793                    "context pack catalog is full",
11794                )
11795            })
11796    }
11797
11798    fn commit_context_pack_for_session_record(
11799        &self,
11800        expected: &ManagedSessionRecord,
11801        identity: Option<&ProviderSessionIdentity>,
11802        session: &SessionAddress,
11803        candidate_id: &str,
11804        loaded: &HistorySessionRecord,
11805        pack: NodeContextPack,
11806        allow_clean_detachment: bool,
11807    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11808        let current_root = self.workspace_root(&expected.workspace_id)?;
11809        let _transaction = self
11810            .state_transaction
11811            .lock()
11812            .unwrap_or_else(|poisoned| poisoned.into_inner());
11813        let records = self
11814            .session_records
11815            .lock()
11816            .unwrap_or_else(|poisoned| poisoned.into_inner());
11817        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11818            failure(
11819                NodeFailureCode::SessionRecordConflict,
11820                "managed session record disappeared during context export",
11821            )
11822        })?;
11823        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11824            return Err(failure(
11825                NodeFailureCode::SessionRecordConflict,
11826                "managed session record changed during context export",
11827            ));
11828        }
11829        let bindings = self
11830            .session_bindings
11831            .lock()
11832            .unwrap_or_else(|poisoned| poisoned.into_inner());
11833        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11834            failure(
11835                NodeFailureCode::SessionRecordConflict,
11836                "managed session runtime binding disappeared during context export",
11837            )
11838        })?;
11839        let runtime = self
11840            .handle
11841            .snapshot()
11842            .sessions
11843            .iter()
11844            .find(|snapshot| {
11845                snapshot.instance_id == session.session.instance_id
11846                    && snapshot.generation == session.session.generation
11847            })
11848            .cloned()
11849            .ok_or_else(|| {
11850                failure(
11851                    NodeFailureCode::SessionRecordConflict,
11852                    "managed session runtime disappeared during context export",
11853                )
11854            })?;
11855        let receipt = pack.receipt();
11856        if !session_record_context_export_binding_is_exact(
11857            expected,
11858            current,
11859            identity,
11860            session,
11861            binding,
11862            &runtime.agent_id,
11863            &current_root,
11864            allow_clean_detachment,
11865        ) || !session_record_context_export_source_is_usable(
11866            current,
11867            &runtime.status,
11868            allow_clean_detachment,
11869        ) || runtime.history.pending.is_some()
11870            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11871            || runtime.history.loaded.as_ref() != Some(loaded)
11872            || receipt.lineage.source_node_id != self.node_id
11873            || receipt.lineage.source_session != *session
11874            || receipt.lineage.source_provider != current.provider
11875            || receipt.source_message_count != loaded.message_count
11876        {
11877            return Err(failure(
11878                NodeFailureCode::SessionRecordConflict,
11879                "managed session source changed before context catalog commit",
11880            ));
11881        }
11882        self.commit_context_pack_durably(&pack)?;
11883        self.context_catalog
11884            .write()
11885            .unwrap_or_else(|poisoned| poisoned.into_inner())
11886            .insert(pack)
11887            .map_err(|_| {
11888                failure(
11889                    NodeFailureCode::ContextPackMaterializationFailed,
11890                    "context pack catalog is full",
11891                )
11892            })
11893    }
11894
11895    async fn context_pack_repository(
11896        &self,
11897        workspace_id: &WorkspaceId,
11898    ) -> Result<ContextPackRepository, NodeFailure> {
11899        let first = self
11900            .context_pack_repository_observation(workspace_id)
11901            .await?;
11902        let second = self
11903            .context_pack_repository_observation(workspace_id)
11904            .await?;
11905        stable_context_pack_repository(first, second)
11906    }
11907
11908    async fn context_pack_repository_observation(
11909        &self,
11910        workspace_id: &WorkspaceId,
11911    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11912        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11913        let head = if git.is_repository {
11914            let root = self.workspace_root(workspace_id)?;
11915            match resolve_base_commit_with_timeout(
11916                &root,
11917                "HEAD",
11918                GIT_COMMAND_TIMEOUT_MS,
11919            )
11920            .await
11921            {
11922                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11923                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11924                    ContextPackRepositoryHead::Unborn
11925                }
11926                Err(_) => {
11927                    return Err(failure(
11928                        NodeFailureCode::ContextPackMaterializationFailed,
11929                        "context pack HEAD observation failed",
11930                    ));
11931                }
11932            }
11933        } else {
11934            ContextPackRepositoryHead::NotRepository
11935        };
11936        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11937        for selected_path in CONTEXT_PACK_SELECTED_FILES {
11938            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11939                failure(
11940                    NodeFailureCode::InvalidRepositoryPath,
11941                    "context pack selected file path is invalid",
11942                )
11943            })?;
11944            match self.read_workspace_file(workspace_id.clone(), path).await {
11945                Ok(WorkspaceFileRead {
11946                    content: WorkspaceFileContent::Utf8 { text, byte_len },
11947                    ..
11948                }) => files.push(ContextPackRepositoryFileSource::utf8(
11949                    selected_path,
11950                    text,
11951                    byte_len,
11952                )),
11953                Ok(WorkspaceFileRead {
11954                    content: WorkspaceFileContent::NonUtf8 { byte_len },
11955                    ..
11956                }) => files.push(ContextPackRepositoryFileSource::skipped(
11957                    selected_path,
11958                    ContextPackSelectedFileSkipReason::NonUtf8,
11959                    Some(byte_len),
11960                )),
11961                Ok(WorkspaceFileRead {
11962                    content: WorkspaceFileContent::TooLarge { .. },
11963                    ..
11964                }) => files.push(ContextPackRepositoryFileSource::skipped(
11965                    selected_path,
11966                    ContextPackSelectedFileSkipReason::TooLarge,
11967                    None,
11968                )),
11969                Err(error) => {
11970                    let reason = if matches!(
11971                        error.code,
11972                        NodeFailureCode::InvalidRepositoryPath
11973                            | NodeFailureCode::RepositoryFileNotRegular
11974                            | NodeFailureCode::RepositoryPathUnsafe
11975                    ) {
11976                        ContextPackSelectedFileSkipReason::Unsafe
11977                    } else {
11978                        ContextPackSelectedFileSkipReason::Unavailable
11979                    };
11980                    files.push(ContextPackRepositoryFileSource::skipped(
11981                        selected_path,
11982                        reason,
11983                        None,
11984                    ));
11985                }
11986            }
11987        }
11988        Ok(ContextPackRepositoryObservation { head, git, files })
11989    }
11990
11991    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11992        let referenced_by_binding = self
11993            .session_bindings
11994            .lock()
11995            .unwrap_or_else(|poisoned| poisoned.into_inner())
11996            .values()
11997            .any(|binding| {
11998                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11999            });
12000        let referenced_by_record = self
12001            .session_records
12002            .lock()
12003            .unwrap_or_else(|poisoned| poisoned.into_inner())
12004            .records
12005            .values()
12006            .any(|record| record.context_id.as_ref() == Some(context_id));
12007        let referenced_by_materialization = self
12008            .materializations
12009            .lock()
12010            .unwrap_or_else(|poisoned| poisoned.into_inner())
12011            .values()
12012            .any(|ownership| {
12013                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12014            });
12015        if referenced_by_binding
12016            || referenced_by_record
12017            || referenced_by_materialization
12018        {
12019            return Err(failure(
12020                NodeFailureCode::ContextPackBusy,
12021                "context pack is still referenced",
12022            ));
12023        }
12024        let mut catalog = self.context_catalog
12025            .write()
12026            .unwrap_or_else(|poisoned| poisoned.into_inner());
12027        if catalog.get(context_id).is_none() {
12028            return Err(failure(
12029                NodeFailureCode::UnknownContextPack,
12030                "context pack is unavailable",
12031            ));
12032        }
12033        self.spawn_idempotency
12034            .lock()
12035            .unwrap_or_else(|poisoned| poisoned.into_inner())
12036            .entries
12037            .retain(|_, entry| !entry.value.references_context(context_id));
12038        let removed = catalog.remove(context_id);
12039        debug_assert!(removed.is_some(), "validated context pack remains present");
12040        Ok(())
12041    }
12042
12043    async fn dispatch_input_bounded(
12044        &self,
12045        address: &SessionAddress,
12046        action: InputAction,
12047    ) -> Result<(), NodeFailure> {
12048        let started_after = self.current_sequence();
12049        self.pty_input_marks
12050            .lock()
12051            .unwrap_or_else(|poisoned| poisoned.into_inner())
12052            .insert(address.session.instance_id, (Instant::now(), false));
12053        self.pty_input_wake.notify_one();
12054        let command_id = self
12055            .dispatch_bounded(
12056                ControlCommand::SendInput {
12057                    instance_id: address.session.instance_id,
12058                    action,
12059                },
12060                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12061            )
12062            .await?;
12063        // A semantic prompt may spend the provider's full readiness budget before
12064        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12065        // but strictly beyond every readiness policy in this node's catalog.
12066        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12067        let mut scan_after = started_after;
12068        let mut accepted_after = None;
12069        loop {
12070            let events = self
12071                .history
12072                .lock()
12073                .unwrap_or_else(|poisoned| poisoned.into_inner())
12074                .events
12075                .iter()
12076                .filter(|envelope| envelope.sequence > scan_after)
12077                .cloned()
12078                .collect::<Vec<_>>();
12079            for envelope in events {
12080                scan_after = scan_after.max(envelope.sequence);
12081                let NodeEvent::Control {
12082                    address: event_address,
12083                    event,
12084                } = envelope.event
12085                else {
12086                    continue;
12087                };
12088                if event_address != *address {
12089                    continue;
12090                }
12091                if event.command_id == Some(command_id) {
12092                    match &event.event {
12093                        ControlEventKind::CommandRejected { message } => {
12094                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12095                        }
12096                        ControlEventKind::InputRequested { .. } => {
12097                            accepted_after = Some(envelope.sequence);
12098                            continue;
12099                        }
12100                        _ => {}
12101                    }
12102                }
12103                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12104                    match &event.event {
12105                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12106                        ControlEventKind::InputFailed { message, .. } => {
12107                            return Err(failure(
12108                                NodeFailureCode::BackendOperationFailed,
12109                                message,
12110                            ));
12111                        }
12112                        _ => {}
12113                    }
12114                }
12115            }
12116            if Instant::now() >= deadline {
12117                return Err(failure(
12118                    NodeFailureCode::BackendBusy,
12119                    if accepted_after.is_some() {
12120                        "input was accepted but did not settle before the bounded deadline"
12121                    } else {
12122                        "input was not accepted before the bounded deadline"
12123                    },
12124                ));
12125            }
12126            sleep(Duration::from_millis(2)).await;
12127        }
12128    }
12129
12130    async fn dispatch_resize_bounded(
12131        &self,
12132        address: &SessionAddress,
12133        size: gate4agent_types::TerminalSize,
12134    ) -> Result<(), NodeFailure> {
12135        let started_after = self.current_sequence();
12136        let command_id = self
12137            .dispatch_bounded(
12138                ControlCommand::Resize {
12139                    instance_id: address.session.instance_id,
12140                    size,
12141                },
12142                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12143            )
12144            .await?;
12145        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12146        let mut scan_after = started_after;
12147        let mut accepted_after = None;
12148        loop {
12149            let events = self
12150                .history
12151                .lock()
12152                .unwrap_or_else(|poisoned| poisoned.into_inner())
12153                .events
12154                .iter()
12155                .filter(|envelope| envelope.sequence > scan_after)
12156                .cloned()
12157                .collect::<Vec<_>>();
12158            for envelope in events {
12159                scan_after = scan_after.max(envelope.sequence);
12160                let NodeEvent::Control {
12161                    address: event_address,
12162                    event,
12163                } = envelope.event
12164                else {
12165                    continue;
12166                };
12167                if event_address != *address {
12168                    continue;
12169                }
12170                if event.command_id == Some(command_id) {
12171                    match &event.event {
12172                        ControlEventKind::CommandRejected { message } => {
12173                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12174                        }
12175                        ControlEventKind::ResizeRequested {
12176                            size: requested_size,
12177                            ..
12178                        } if *requested_size == size => {
12179                            accepted_after = Some(envelope.sequence);
12180                            continue;
12181                        }
12182                        _ => {}
12183                    }
12184                }
12185                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12186                    match &event.event {
12187                        ControlEventKind::Resized { size: settled_size }
12188                            if *settled_size == size =>
12189                        {
12190                            return Ok(());
12191                        }
12192                        ControlEventKind::ResizeFailed { message } => {
12193                            return Err(failure(
12194                                NodeFailureCode::BackendOperationFailed,
12195                                message,
12196                            ));
12197                        }
12198                        _ => {}
12199                    }
12200                }
12201            }
12202            if Instant::now() >= deadline {
12203                return Err(failure(
12204                    NodeFailureCode::BackendBusy,
12205                    if accepted_after.is_some() {
12206                        "resize was accepted but did not settle before the bounded deadline"
12207                    } else {
12208                        "resize was not accepted before the bounded deadline"
12209                    },
12210                ));
12211            }
12212            sleep(Duration::from_millis(2)).await;
12213        }
12214    }
12215
12216    async fn spawn_session(
12217        &self,
12218        workspace_id: WorkspaceId,
12219        provider: AgentId,
12220        mode: SessionMode,
12221        terminal_size: gate4agent_types::TerminalSize,
12222        initial_prompt: Option<String>,
12223    ) -> Result<SessionAddress, NodeFailure> {
12224        if self.managed_worktrees.lock()
12225            .unwrap_or_else(|poisoned| poisoned.into_inner())
12226            .lease_for_workspace(&workspace_id)
12227            .is_some()
12228        {
12229            return Err(failure(
12230                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12231                "managed worktree targets may only be spawned through their lease request",
12232            ));
12233        }
12234        self
12235            .spawn_session_with_deadline(
12236                workspace_id,
12237                provider,
12238                mode,
12239                terminal_size,
12240                initial_prompt,
12241                None,
12242                None,
12243                None,
12244                None,
12245                None,
12246                SpawnRecordPolicy::ProviderIdentityOnly,
12247                None,
12248                &[],
12249                None,
12250                // `NodeRequest::Spawn` (this method's only caller) carries no
12251                // approval-level selection of its own -- see
12252                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12253                // the one operator-wire path that can choose something other
12254                // than the axis default.
12255                ApprovalLevel::default(),
12256                None,
12257                None,
12258            )
12259            .await
12260            .map(|(session, _runtime_policy)| session)
12261    }
12262
12263    async fn spawn_session_with_deadline(
12264        &self,
12265        workspace_id: WorkspaceId,
12266        provider: AgentId,
12267        mode: SessionMode,
12268        terminal_size: gate4agent_types::TerminalSize,
12269        initial_prompt: Option<String>,
12270        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12271        bundle: Option<ResolvedBundleReceipt>,
12272        context: Option<ResolvedContextPackReceipt>,
12273        managed_authority: Option<ManagedWorktreeLeaseId>,
12274        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12275        record_policy: SpawnRecordPolicy,
12276        deadline: Option<Instant>,
12277        required_capabilities: &[ProviderRuntimeCapability],
12278        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12279        approval_level: ApprovalLevel,
12280        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12281        // Opaque dig2browser station profile id (ids only). Feature
12282        // `dig2-station-probe`: after probe at resolve, spawn takes an
12283        // exclusive node-local lease; cleared on session end. Never
12284        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12285        // already happened on the receipt path).
12286        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12287    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12288        let reserved_lease = self.managed_worktrees.lock()
12289            .unwrap_or_else(|poisoned| poisoned.into_inner())
12290            .lease_for_workspace(&workspace_id);
12291        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12292            (Some(expected), Some(provided)) if expected == provided => {}
12293            (None, None) => {}
12294            _ => {
12295                return Err(failure(
12296                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12297                    "spawn lacks exact managed worktree lease authority",
12298                ));
12299            }
12300        }
12301        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12302        // bindings) on the same workspace cwd. Parallel path: managed worktree
12303        // spawn so each profile gets its own workspace_id.
12304        self.ensure_workspace_environment_profile_compatible(
12305            &workspace_id,
12306            environment_profile.as_ref(),
12307        )?;
12308        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12309            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12310            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12311            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12312            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12313        };
12314        // This session's own transport -- computed once, here, from `mode`,
12315        // and reused both to admit spawn (below) and, further down, to
12316        // compute the final `runtime_policy` this session actually operates
12317        // under (`provider_runtime::policy_for_transport`). Registration
12318        // reuses this exact value rather than recomputing it a second time.
12319        let transport = match mode {
12320            SessionMode::Pty => TransportKind::Pty,
12321            SessionMode::Inline => TransportKind::Pipe,
12322            SessionMode::Acp => TransportKind::Acp,
12323        };
12324        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12325        let acp_transport = self.provider_supports_acp_transport(&provider);
12326        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12327            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12328                NodeFailureCode::UnsupportedSpawnCapability,
12329                "pre-admitted provider runtime no longer satisfies spawn requirements",
12330            ))?;
12331            runtime_policy
12332        } else if let Some(deadline) = deadline {
12333            let remaining = spawn_deadline_remaining(deadline)?;
12334            timeout(
12335                remaining,
12336                self.admit_provider_runtime(&provider, runtime_requirement),
12337            )
12338            .await
12339            .map_err(|_| {
12340                failure(
12341                    NodeFailureCode::SpawnDeadlineExceeded,
12342                    "provider admission exceeded the spawn deadline",
12343                )
12344            })??
12345        } else {
12346            self.admit_provider_runtime(&provider, runtime_requirement).await?
12347        };
12348        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12349        // The single point where this session's `runtime_policy` becomes
12350        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12351        // branch above) only ever compute a PTY-terminal-verification-shaped
12352        // value, sound outright only for `TransportKind::Pty` (Pipe still
12353        // relies on that same value downstream -- see `policy_for_transport`'s
12354        // own doc for why). `policy_for_transport` is the explicit,
12355        // exhaustive match Item 1 asks for -- this is the ONE place its
12356        // output becomes the session's `runtime_policy`, so every later
12357        // reader (`ControlCommand::Start`, `session_bindings`, and every
12358        // engine gate that reads `state.runtime_policy`) sees the same,
12359        // correctly-computed value.
12360        let runtime_policy =
12361            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12362        if required_capabilities
12363            .iter()
12364            .any(|capability| !runtime_policy.admits(*capability))
12365        {
12366            return Err(failure(
12367                NodeFailureCode::UnsupportedSpawnCapability,
12368                "provider runtime does not admit a required spawn capability",
12369            ));
12370        }
12371        if let Some(deadline) = deadline {
12372            spawn_deadline_remaining(deadline)?;
12373        }
12374        self.ensure_binding_capacity()?;
12375        let working_directory = self.workspace_root(&workspace_id)?;
12376        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12377        let session = SessionKey {
12378            instance_id,
12379            generation: SessionGeneration(1),
12380        };
12381        let address = SessionAddress {
12382            workspace_id: workspace_id.clone(),
12383            session,
12384        };
12385        // This is the request-accepted checkpoint: admission (managed
12386        // worktree authority, runtime capability, dispatch deadline) already
12387        // passed and a concrete session identity is minted. It is a distinct
12388        // state from the process actually running (`ControlEventKind::Running`,
12389        // logged in `publish_control`) or the terminal outcome (`Exited`/
12390        // `Failed`, logged there too) -- this line exists so that gap is no
12391        // longer silent.
12392        tracing::info!(
12393            node_id = %self.node_id,
12394            workspace_id = %workspace_id,
12395            provider = %provider,
12396            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12397            session = ?address.session,
12398            "session spawn request accepted",
12399        );
12400        let mut prepared_spawn = SpawnPreparer::prepare(
12401            self,
12402            SpawnPrepareRequest {
12403                instance_id,
12404                address: &address,
12405                provider: &provider,
12406                mode,
12407                environment_profile: environment_profile.as_ref(),
12408                bundle: bundle.as_ref(),
12409                context: context.as_ref(),
12410                managed_authority: managed_authority.as_ref(),
12411                harness_mcp,
12412                approval_level,
12413                network_allowlist: network_allowlist.as_ref(),
12414                browser_profile_id,
12415            },
12416        )?;
12417        let materialization_id = prepared_spawn.materialization_id.take();
12418        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12419        let record_id = self.bind_spawn_session_with_materialization(
12420            &address,
12421            provider.clone(),
12422            mode,
12423            runtime_policy,
12424            record_policy,
12425            environment_profile,
12426            bundle,
12427            context,
12428            materialization_id,
12429        )?;
12430        prepared_spawn.retain_session_guards();
12431        // `transport` was already computed above, alongside this session's
12432        // `runtime_policy`; reused here for `Register` rather than derived a
12433        // second time.
12434        let agent_id = provider;
12435        let register_command_id = match self.dispatch_bounded(
12436            ControlCommand::Register {
12437                instance_id,
12438                agent_id,
12439                transport,
12440            },
12441            dispatch_timeout,
12442        )
12443        .await {
12444            Ok(command_id) => command_id,
12445            Err(error) => {
12446                if let Some(binding) = self.remove_binding(&address) {
12447                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12448                }
12449                if let Some(record_id) = record_id.as_ref() {
12450                    self.discard_record(record_id)?;
12451                }
12452                return Err(spawn_dispatch_error(error, deadline));
12453            }
12454        };
12455        let start_timeout = match spawn_dispatch_timeout(deadline) {
12456            Ok(timeout) => timeout,
12457            Err(error) => {
12458                let recovery = self
12459                    .rollback_spawn(
12460                        &address,
12461                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12462                    )
12463                    .await;
12464                return match recovery {
12465                    Ok(()) => {
12466                        if let Some(record_id) = record_id.as_ref() {
12467                            self.discard_record(record_id)?;
12468                        }
12469                        Err(error)
12470                    }
12471                    Err(recovery_error) => {
12472                        if let Some(record_id) = record_id.as_ref() {
12473                            self.mark_record_error(
12474                                record_id,
12475                                "spawn deadline elapsed and registration recovery failed",
12476                            )?;
12477                        }
12478                        Err(recovery_error)
12479                    }
12480                };
12481            }
12482        };
12483        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12484            if let Err(recovery_error) = self
12485                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12486                .await
12487            {
12488                return Err(recovery_error);
12489            }
12490            if let Some(record_id) = record_id.as_ref() {
12491                self.discard_record(record_id)?;
12492            }
12493            return Err(start_error);
12494        }
12495        let start_result = self
12496            .dispatch_bounded(
12497                ControlCommand::Start {
12498                    instance_id,
12499                    runtime_policy,
12500                    request: StartRequest {
12501                        working_directory,
12502                        terminal_size,
12503                        initial_prompt,
12504                        session_options: None,
12505                        approval_level,
12506                    },
12507                },
12508                start_timeout,
12509            )
12510            .await;
12511        let start_command_id = match start_result {
12512            Ok(command_id) => command_id,
12513            Err(start_error) => {
12514                let start_error = spawn_dispatch_error(start_error, deadline);
12515                let recovery = self
12516                    .rollback_spawn(
12517                        &address,
12518                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12519                    )
12520                    .await;
12521                return match recovery {
12522                    Ok(()) => {
12523                        if let Some(record_id) = record_id.as_ref() {
12524                            self.discard_record(record_id)?;
12525                        }
12526                        Err(start_error)
12527                    }
12528                    Err(recovery_error) => {
12529                        let diagnostic = format!(
12530                            "start failed and registration recovery failed: {}",
12531                            recovery_error.message,
12532                        );
12533                        if let Some(record_id) = record_id.as_ref() {
12534                            self.mark_record_error(record_id, &diagnostic)?;
12535                        }
12536                        Err(failure(recovery_error.code, &diagnostic))
12537                    }
12538                };
12539            }
12540        };
12541
12542        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12543        loop {
12544            // `Register`/`Start` only fail synchronously for a port-level
12545            // ingress problem (see `dispatch_bounded`); the kernel refusing
12546            // either command's content -- an unsupported transport, an
12547            // unknown agent -- lands one drive-loop tick later, and no
12548            // session ever appears in `self.handle.snapshot()` for it to
12549            // notice below. Check for that named reason before falling
12550            // through to the blind presence poll, so a rejected spawn
12551            // reports why instead of just timing out.
12552            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12553                .or_else(|| self.take_rejected_command_reason(start_command_id))
12554            {
12555                self.rollback_spawn(
12556                    &address,
12557                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12558                ).await.map_err(|error| {
12559                        failure(
12560                            error.code,
12561                            &format!(
12562                                "spawn was rejected ({}) and registration recovery failed: {}",
12563                                reason.message, error.message
12564                            ),
12565                        )
12566                    })?;
12567                if let Some(record_id) = record_id.as_ref() {
12568                    self.discard_record(record_id)?;
12569                }
12570                let code = if reason.unsupported_transport {
12571                    NodeFailureCode::UnsupportedTransport
12572                } else {
12573                    NodeFailureCode::BackendOperationFailed
12574                };
12575                return Err(failure(code, &reason.message));
12576            }
12577            if let Some(current) = self
12578                .handle
12579                .snapshot()
12580                .sessions
12581                .iter()
12582                .find(|current| current.instance_id == instance_id)
12583            {
12584                if current.generation != session.generation {
12585                    let divergence = failure(
12586                        NodeFailureCode::StaleGeneration,
12587                        "spawn generation diverged",
12588                    );
12589                    return match self
12590                        .rollback_spawn(
12591                            &address,
12592                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12593                        )
12594                        .await
12595                    {
12596                        Ok(()) => {
12597                            if let Some(record_id) = record_id.as_ref() {
12598                                self.discard_record(record_id)?;
12599                            }
12600                            Err(divergence)
12601                        }
12602                        Err(recovery_error) => {
12603                            if let Some(record_id) = record_id.as_ref() {
12604                                self.mark_record_error(
12605                                    record_id,
12606                                    "spawn generation diverged and recovery failed",
12607                                )?;
12608                            }
12609                            Err(recovery_error)
12610                        }
12611                    };
12612                }
12613                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12614                    prepared_spawn.retain_launch_guards();
12615                    return Ok((address, runtime_policy));
12616                }
12617            }
12618            if Instant::now() >= commit_deadline {
12619                self.rollback_spawn(
12620                    &address,
12621                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12622                ).await.map_err(|error| {
12623                        failure(
12624                            error.code,
12625                            &format!(
12626                                "start did not commit and registration recovery failed: {}",
12627                                error.message
12628                            ),
12629                        )
12630                    })?;
12631                if let Some(record_id) = record_id.as_ref() {
12632                    self.discard_record(record_id)?;
12633                }
12634                return Err(failure(
12635                    if deadline.is_some() {
12636                        NodeFailureCode::SpawnDeadlineExceeded
12637                    } else {
12638                        NodeFailureCode::BackendBusy
12639                    },
12640                    "start did not commit before the bounded deadline; registration was removed",
12641                ));
12642            }
12643            sleep(Duration::from_millis(2)).await;
12644        }
12645    }
12646
12647    fn effective_spawn_runtime_policy(
12648        &self,
12649        runtime_policy: ProviderRuntimePolicy,
12650    ) -> ProviderRuntimePolicy {
12651        #[cfg(feature = "fixture")]
12652        let runtime_policy = if self.fixture_semantic_hook_policy {
12653            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12654                .expect("monitoring Hook fixture policy is internally valid")
12655        } else {
12656            runtime_policy
12657        };
12658        runtime_policy
12659    }
12660
12661    fn verify_harness_mcp_before_start(
12662        &self,
12663        prepared: Option<&PreparedHarnessMcpSpawn>,
12664    ) -> Result<(), NodeFailure> {
12665        prepared
12666            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12667            .transpose()
12668            .map(|_| ())
12669            .map_err(harness_mcp_failure)
12670    }
12671
12672    async fn rollback_spawn(
12673        &self,
12674        address: &SessionAddress,
12675        commit_timeout: Duration,
12676    ) -> Result<(), NodeFailure> {
12677        self.dispatch_bounded(
12678            ControlCommand::Remove {
12679                instance_id: address.session.instance_id,
12680            },
12681            commit_timeout,
12682        )
12683        .await?;
12684        self.wait_until_removed(address, commit_timeout).await?;
12685        if let Some(binding) = self.remove_binding(address) {
12686            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12687        }
12688        Ok(())
12689    }
12690
12691    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12692        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12693        self.dispatch_bounded(
12694            ControlCommand::Remove {
12695                instance_id: address.session.instance_id,
12696            },
12697            commit_timeout,
12698        )
12699            .await?;
12700        self.wait_until_removed(address, commit_timeout).await?;
12701        let removed = self.remove_binding(address);
12702        if let Some(binding) = removed.as_ref() {
12703            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12704        }
12705        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12706            let snapshot = self.managed_worktrees.lock()
12707                .unwrap_or_else(|poisoned| poisoned.into_inner())
12708                .release_session(
12709                    &lease_id,
12710                    self.incarnation_id,
12711                    address.session.instance_id,
12712                    address.session.generation,
12713                    unix_time_ms(),
12714                );
12715            if self.persist_state().is_err() {
12716                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12717            }
12718            if let Some(snapshot) = snapshot {
12719                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12720            }
12721            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12722        }
12723        Ok(())
12724    }
12725
12726    /// Polls until `address`'s session leaves the engine's own session map,
12727    /// or the bounded deadline expires.
12728    ///
12729    /// A `Remove` sent while the session is not yet in a terminal status
12730    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12731    /// once by the engine (`SessionStatus::allows_remove`) and
12732    /// then discarded -- nothing re-issues it on its own, so the removal
12733    /// that would satisfy this wait never arrives and the caller was
12734    /// guaranteed to time out even though the session settles microseconds
12735    /// later. So this re-dispatches on every poll tick: a session that
12736    /// settles mid-wait -- the common case right after a `Stop` -- still
12737    /// gets removed inside the same bounded deadline.
12738    ///
12739    /// It re-dispatches only once the status will ACCEPT the command. A
12740    /// rejected re-dispatch is not the harmless no-op this loop first
12741    /// assumed: every rejection publishes a `ControlEventKind::Command
12742    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12743    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12744    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12745    /// out to the harness and every attached client. Gated, the same
12746    /// teardown sends exactly one `Remove`, at the first tick the engine
12747    /// can act on it.
12748    ///
12749    /// The status is read from the snapshot this loop already takes, so
12750    /// the gate costs nothing extra. It races -- the status can move
12751    /// between the read and the dispatch -- and that is fine: a rare lost
12752    /// race is one rejection, and the next tick retries.
12753    ///
12754    /// Re-dispatch is separately rate-limited to
12755    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12756    /// leaves a second burst behind it. Once the session settles, every
12757    /// 2ms tick sent another `Remove` while the accepted one was still in
12758    /// flight, and each of those landed on an instance the engine had
12759    /// already dropped -- `agent instance … is not registered`, observed
12760    /// live as a tail of rejections immediately after the `session
12761    /// removed` line. The presence poll stays at 2ms so the wait still
12762    /// exits promptly; only the sending slows down.
12763    ///
12764    /// Rate-limited rather than once-only on purpose: the status gate
12765    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12766    /// others (a pending operation, an in-flight capability probe or
12767    /// history request) that clear on their own. A single shot would turn
12768    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12769    /// get their retries, just twenty of them across the deadline instead
12770    /// of a thousand.
12771    async fn wait_until_removed(
12772        &self,
12773        address: &SessionAddress,
12774        commit_timeout: Duration,
12775    ) -> Result<(), NodeFailure> {
12776        let deadline = Instant::now() + commit_timeout;
12777        let mut next_dispatch = Instant::now();
12778        loop {
12779            let snapshot = self.handle.snapshot();
12780            let status = snapshot
12781                .sessions
12782                .iter()
12783                .find(|session| session.instance_id == address.session.instance_id)
12784                .map(|session| session.status.clone());
12785            let Some(status) = status else {
12786                return Ok(());
12787            };
12788            let now = Instant::now();
12789            if now >= deadline {
12790                return Err(failure(
12791                    NodeFailureCode::BackendBusy,
12792                    "remove did not commit before the bounded deadline; session binding was retained",
12793                ));
12794            }
12795            if status.allows_remove() && now >= next_dispatch {
12796                let _ = self.dispatch(ControlCommand::Remove {
12797                    instance_id: address.session.instance_id,
12798                });
12799                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12800            }
12801            sleep(Duration::from_millis(2)).await;
12802        }
12803    }
12804
12805    fn resync(&self, after_sequence: u64) -> NodeResponse {
12806        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12807        let event_sequence = history.last_sequence;
12808        let oldest_available_sequence = history.replay_floor_sequence;
12809        let events = history
12810            .events
12811            .iter()
12812            .filter(|event| event.sequence > after_sequence)
12813            .cloned()
12814            .collect();
12815        drop(history);
12816        NodeResponse::Resync {
12817            event_sequence,
12818            oldest_available_sequence,
12819            snapshot: self.snapshot(),
12820            events,
12821        }
12822    }
12823}
12824
12825/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12826/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12827/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12828/// "name what crossed this boundary" reasoning, one layer upstream of it).
12829fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12830    match kind {
12831        AgentStreamChunkKindV1::Text { .. } => "text",
12832        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12833        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12834        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12835        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12836        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12837        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12838    }
12839}
12840
12841/// Serves this wire on the local endpoint, and -- when `call_home` names a
12842/// relay -- over an outbound connection to it as well.
12843///
12844/// The two live in one future rather than a fourth arm of `run`'s own
12845/// `select!` because they are one concern: this is where the node's wire
12846/// is served, and the direction a given connection was opened in does not
12847/// change what happens on it. It also means the existing graceful drain
12848/// below covers both without being duplicated.
12849///
12850/// Either side finishing ends the pair, which is correct: the listener
12851/// only returns on shutdown or a bind failure, and `call_home_loop` only
12852/// returns on shutdown -- a failed relay connection is retried inside it,
12853/// never propagated out.
12854async fn accept_connections(
12855    endpoint: &str,
12856    call_home: Option<std::net::SocketAddr>,
12857    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12858    shared: Arc<NodeShared>,
12859) -> Result<(), NodeServerError> {
12860    let mut connections = JoinSet::new();
12861    let result = match call_home {
12862        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12863        Some(relay) => {
12864            let listener =
12865                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12866            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12867            tokio::pin!(listener);
12868            tokio::pin!(dialler);
12869            tokio::select! {
12870                result = &mut listener => result,
12871                result = &mut dialler => result,
12872            }
12873        }
12874    };
12875    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12876    while !connections.is_empty() {
12877        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12878        if remaining.is_zero()
12879            || timeout(remaining, connections.join_next()).await.is_err()
12880        {
12881            break;
12882        }
12883    }
12884    connections.abort_all();
12885    while connections.join_next().await.is_some() {}
12886    result
12887}
12888
12889/// Backoff ladder for a relay that will not answer, capped rather than
12890/// unbounded: a node whose relay is down is not broken and should keep
12891/// trying forever, but it should not spend a core doing it.
12892const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12893
12894/// Dials the relay, serves this wire over the connection, and does it
12895/// again when the connection ends.
12896///
12897/// The node is the SERVER on the socket it just opened. That inversion is
12898/// the whole point: `serve_connection` is the same function the local
12899/// listener feeds, and it neither knows nor cares which end placed the
12900/// call. The one thing this direction adds is the preface -- the relay
12901/// accepted a socket and cannot know whose it is, so the node says its
12902/// name before the handshake it would otherwise begin with.
12903///
12904/// Never returns an error. A relay that is down, refusing, or speaking a
12905/// different protocol is a condition to retry, not a reason to take the
12906/// node's local endpoint down with it; the only way out is shutdown.
12907async fn call_home_loop(
12908    relay: std::net::SocketAddr,
12909    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12910    shared: Arc<NodeShared>,
12911) -> Result<(), NodeServerError> {
12912    let mut failures = 0_usize;
12913    loop {
12914        if shared.shutdown.load(Ordering::Acquire) {
12915            return Ok(());
12916        }
12917        let permit = tokio::select! {
12918            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12919                permit.map_err(|_| NodeServerError::PreauthClosed)?
12920            }
12921            _ = shared.shutdown_notify.notified() => return Ok(()),
12922        };
12923        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
12924            Ok(()) => {
12925                // A clean end is the relay hanging up, which it does on
12926                // its own restart. Reconnect immediately rather than
12927                // serving the backoff a peer that is plainly there.
12928                failures = 0;
12929                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12930            }
12931            Err(error) => {
12932                failures = failures.saturating_add(1);
12933                tracing::warn!(
12934                    node_id = %shared.node_id,
12935                    %relay,
12936                    cause = %error,
12937                    attempt = failures,
12938                    "call-home connection failed",
12939                );
12940                let backoff = CALL_HOME_BACKOFFS_MS
12941                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12942                tokio::select! {
12943                    _ = sleep(Duration::from_millis(backoff)) => {}
12944                    _ = shared.shutdown_notify.notified() => return Ok(()),
12945                }
12946            }
12947        }
12948    }
12949}
12950
12951/// One call-home attempt: connect, name this node, then serve.
12952async fn dial_relay_and_serve(
12953    relay: std::net::SocketAddr,
12954    identity: Option<&hatchery_enroll::NodeIdentity>,
12955    shared: Arc<NodeShared>,
12956    preauth_permit: OwnedSemaphorePermit,
12957) -> Result<(), NodeServerError> {
12958    let mut stream = tokio::net::TcpStream::connect(relay)
12959        .await
12960        .map_err(NodeServerError::CallHomeTransport)?;
12961    // Nagle off for the same reason the rest of this wire wants it off:
12962    // the traffic is small request/response frames, and coalescing them
12963    // adds latency to every one.
12964    let _ = stream.set_nodelay(true);
12965    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12966        .await
12967        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12968    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12969    let mut session_token = None;
12970    if let Some(identity) = identity {
12971        // An enrolled relay answers the announce with a signed-call-home challenge; a
12972        // token-mode relay sends its wire hello instead. Peek to tell them apart
12973        // without consuming either.
12974        let mut magic = [0_u8; 4];
12975        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
12976            .await
12977            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
12978            .map_err(NodeServerError::CallHomeTransport)?;
12979        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
12980            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
12981            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
12982                .await
12983                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
12984                .map_err(NodeServerError::CallHomeTransport)?;
12985            let (answer, token) = identity
12986                .answer_challenge(shared.node_id.as_str(), &frame)
12987                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12988            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
12989            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
12990            session_token = Some(token);
12991        }
12992    }
12993    serve_connection(stream, shared, session_token, preauth_permit).await
12994}
12995
12996async fn accept_connections_inner(
12997    endpoint: &str,
12998    shared: Arc<NodeShared>,
12999    connections: &mut JoinSet<Result<(), NodeServerError>>,
13000) -> Result<(), NodeServerError> {
13001    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13002    loop {
13003        while connections.try_join_next().is_some() {}
13004        if shared.shutdown.load(Ordering::Acquire) {
13005            return Ok(());
13006        }
13007        let preauth_permit = tokio::select! {
13008            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13009                permit.map_err(|_| NodeServerError::PreauthClosed)?
13010            }
13011            _ = shared.shutdown_notify.notified() => return Ok(()),
13012        };
13013        let server = tokio::select! {
13014            result = listener.accept() => result?,
13015            _ = shared.shutdown_notify.notified() => return Ok(()),
13016        };
13017        let connection_shared = Arc::clone(&shared);
13018        connections.spawn(async move {
13019            serve_connection(server, connection_shared, None, preauth_permit).await
13020        });
13021        if shared.shutdown.load(Ordering::Acquire) {
13022            return Ok(());
13023        }
13024    }
13025}
13026
13027/// Serves one connection, whichever end opened it.
13028///
13029/// Generic over the stream rather than typed to `LocalServerStream`
13030/// because a call-home connection arrives on a `TcpStream` the node itself
13031/// dialled. Nothing in the body ever depended on the concrete type -- it
13032/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13033/// the whole of what welding "who dials" to "who serves" was costing.
13034async fn serve_connection<S>(
13035    mut pipe: S,
13036    shared: Arc<NodeShared>,
13037    session_token: Option<String>,
13038    preauth_permit: OwnedSemaphorePermit,
13039) -> Result<(), NodeServerError>
13040where
13041    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13042{
13043    // A signed call-home connection authenticates with its own per-connection token.
13044    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13045    let hello_frame = timeout(
13046        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13047        read_json_frame_limited_body_timeout(
13048            &mut pipe,
13049            MAX_NODE_HELLO_FRAME_BYTES,
13050            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13051        ),
13052    )
13053    .await
13054    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13055    let ClientFrame::Hello(hello) = hello_frame else {
13056        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13057    };
13058    if hello.build_stamp != BUILD_STAMP {
13059        return Err(NodeServerError::Handshake(format!(
13060            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13061            hello.build_stamp,
13062        )));
13063    }
13064    let compatibility = match hello.compatibility.as_ref() {
13065        Some(offer) => {
13066            let mut selected = node_compatibility_support(&shared)?
13067                .negotiate(offer)
13068                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13069            project_negotiated_provider_ids(&mut selected);
13070            Some(selected)
13071        }
13072        None => None,
13073    };
13074    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13075    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13076        (Some(offer), Some(selected)) => negotiated_auth_proof(
13077            auth_token,
13078            AuthDirection::Server,
13079            hello.role,
13080            &hello.client_nonce,
13081            &server_nonce,
13082            offer,
13083            selected,
13084        ),
13085        (None, None) => auth_proof(
13086            auth_token,
13087            AuthDirection::Server,
13088            hello.role,
13089            &hello.client_nonce,
13090            &server_nonce,
13091        ),
13092        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13093    }
13094    .map_err(NodeServerError::Authentication)?;
13095    write_json_frame_limited(
13096        &mut pipe,
13097        &ServerFrame::Challenge(ServerChallenge {
13098            build_stamp: BUILD_STAMP.to_owned(),
13099            server_nonce,
13100            server_proof,
13101            compatibility: compatibility.clone(),
13102        }),
13103        MAX_NODE_HELLO_FRAME_BYTES,
13104    )
13105    .await?;
13106    let authentication = timeout(
13107        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13108        read_json_frame_limited_body_timeout(
13109            &mut pipe,
13110            MAX_NODE_HELLO_FRAME_BYTES,
13111            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13112        ),
13113    )
13114    .await
13115    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13116    let ClientFrame::Authenticate(authentication) = authentication else {
13117        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13118    };
13119    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13120        (Some(offer), Some(selected)) => negotiated_auth_proof(
13121            auth_token,
13122            AuthDirection::Client,
13123            hello.role,
13124            &hello.client_nonce,
13125            &server_nonce,
13126            offer,
13127            selected,
13128        ),
13129        (None, None) => auth_proof(
13130            auth_token,
13131            AuthDirection::Client,
13132            hello.role,
13133            &hello.client_nonce,
13134            &server_nonce,
13135        ),
13136        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13137    }
13138    .map_err(NodeServerError::Authentication)?;
13139    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13140        return Err(NodeServerError::Handshake("access denied".to_owned()));
13141    }
13142    let selected_capabilities = compatibility
13143        .as_ref()
13144        .map(|selected| selected.capabilities.clone())
13145        .unwrap_or_default();
13146    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13147        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13148    });
13149    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13150        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13151    });
13152    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13153        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13154    });
13155    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13156        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13157    });
13158    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13159        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13160    });
13161    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13162        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13163    }) && selected_capabilities.iter().any(|capability| {
13164        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13165    });
13166    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13167        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13168    });
13169    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13170        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13171    });
13172    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13173        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13174    });
13175    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13176        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13177    });
13178    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13179        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13180    });
13181    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13182        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13183    });
13184    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13185        .try_acquire_owned()
13186        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13187    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13188    let _release_guard = ControllerReleaseGuard {
13189        shared: Arc::clone(&shared),
13190        connection_id,
13191    };
13192    drop(preauth_permit);
13193    let _authenticated_permit = authenticated_permit;
13194    let mut event_rx = shared.event_tx.subscribe();
13195    let mut terminal_event_rx = terminal_event_subscription(
13196        &shared,
13197        include_terminal_frame_events,
13198    );
13199    let mut agent_stream_event_rx = agent_stream_event_subscription(
13200        &shared,
13201        include_agent_stream_events,
13202    );
13203    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13204        &shared,
13205        include_harness_mcp_proxy,
13206    );
13207    write_json_frame(
13208        &mut pipe,
13209        &ServerFrame::Hello(NodeHello {
13210            build_stamp: BUILD_STAMP.to_owned(),
13211            incarnation_id: shared.incarnation_id,
13212            connection_id,
13213            role: hello.role,
13214            event_sequence: shared.current_sequence(),
13215            controller: shared.controller_state(),
13216            snapshot: snapshot_for_wire(
13217                &shared,
13218                include_provider_runtime_status,
13219                include_open_provider_ids,
13220                include_spawn_profiles,
13221                include_managed_worktrees,
13222                include_child_environment_profiles,
13223                include_session_bundles,
13224                include_history_context_packs,
13225                include_agent_progress,
13226                include_session_task_correlation,
13227            ),
13228            compatibility,
13229        }),
13230    )
13231    .await?;
13232    let (mut reader, mut writer) = tokio::io::split(pipe);
13233    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13234    let reader_task = tokio::spawn(async move {
13235        loop {
13236            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13237                &mut reader,
13238                MAX_NODE_CLIENT_FRAME_BYTES,
13239                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13240            )
13241            .await;
13242            let terminal = frame.is_err();
13243            if frame_tx.send(frame).await.is_err() || terminal {
13244                break;
13245            }
13246        }
13247    });
13248    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13249    let mut pending_events = Vec::new();
13250    let mut resync_required = false;
13251    let mut discard_events_through = 0_u64;
13252    loop {
13253        // One tick of the serve loop, whether or not it had anything to do.
13254        // Against `connection_events_sent` this separates "busy" from
13255        // "spinning": a loop turning over thousands of times a second while
13256        // sending nothing is not serving, it is burning a core.
13257        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13258        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13259            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13260        for _ in 0..durable_drain_budget {
13261            match event_rx.try_recv() {
13262                Ok(event) => queue_connection_event(
13263                    &mut pending_events,
13264                    event,
13265                    discard_events_through,
13266                ),
13267                Err(broadcast::error::TryRecvError::Empty) => break,
13268                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13269                    resync_required = true;
13270                    break;
13271                }
13272                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13273            }
13274        }
13275        if let Some(receiver) = terminal_event_rx.as_mut() {
13276            match receiver.try_recv() {
13277                Ok(events) => queue_connection_event_batch(
13278                    &mut pending_events,
13279                    &events,
13280                    discard_events_through,
13281                ),
13282                Err(broadcast::error::TryRecvError::Empty) => {}
13283                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13284                    resync_required = true;
13285                }
13286                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13287            }
13288        }
13289        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13290            // Loops up to the same remaining-room budget the durable
13291            // `event_rx` drain above uses, rather than one `try_recv` per
13292            // tick: each successful receive here is itself only ONE
13293            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13294            // sends `vec![envelope]`, never more), so a single-shot drain
13295            // caps this connection's agent-stream throughput at one chunk
13296            // per outer-loop tick regardless of how many are already queued
13297            // -- exactly the kind of self-inflicted stall this crate's own
13298            // durable-event budget exists to avoid.
13299            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13300                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13301            for _ in 0..agent_stream_drain_budget {
13302                match receiver.try_recv() {
13303                    Ok(events) => queue_connection_event_batch(
13304                        &mut pending_events,
13305                        &events,
13306                        discard_events_through,
13307                    ),
13308                    Err(broadcast::error::TryRecvError::Empty) => break,
13309                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13310                        tracing::warn!(
13311                            connection_id,
13312                            dropped,
13313                            "agent stream event subscription lagged; forcing a resync",
13314                        );
13315                        resync_required = true;
13316                        break;
13317                    }
13318                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13319                }
13320            }
13321        }
13322        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13323            // Mirrors the agent-stream drain immediately above: each
13324            // successful receive is one single-envelope batch
13325            // (`publish_transient` always sends `vec![envelope]`), so this
13326            // loops up to the same remaining-room budget rather than
13327            // stopping after one `try_recv`.
13328            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13329                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13330            for _ in 0..harness_mcp_proxy_drain_budget {
13331                match receiver.try_recv() {
13332                    Ok(events) => queue_connection_event_batch(
13333                        &mut pending_events,
13334                        &events,
13335                        discard_events_through,
13336                    ),
13337                    Err(broadcast::error::TryRecvError::Empty) => break,
13338                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13339                        tracing::warn!(
13340                            connection_id,
13341                            dropped,
13342                            "harness MCP proxy event subscription lagged; forcing a resync",
13343                        );
13344                        resync_required = true;
13345                        break;
13346                    }
13347                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13348                }
13349            }
13350        }
13351        if resync_required {
13352            let event = resync_required_event(&shared);
13353            apply_connection_resync_watermark(
13354                &mut pending_events,
13355                &mut discard_events_through,
13356                event.sequence,
13357            );
13358            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13359            resync_required = false;
13360            continue;
13361        }
13362        if !pending_events.is_empty() {
13363            pending_events.sort_unstable_by_key(|event| event.sequence);
13364            let burst_len = connection_event_burst_len(pending_events.len());
13365            shared
13366                .connection_events_sent
13367                .fetch_add(burst_len as u64, Ordering::Relaxed);
13368            for event in pending_events.drain(..burst_len) {
13369                let event = if include_managed_worktrees {
13370                    Some(event)
13371                } else {
13372                    project_event_without_managed_worktrees(event)
13373                };
13374                let event = event.and_then(|event| if include_open_provider_ids {
13375                    Some(event)
13376                } else {
13377                    project_event_legacy_provider_ids(&shared, event)
13378                });
13379                let event = event.map(|event| if include_child_environment_profiles {
13380                    event
13381                } else {
13382                    project_event_without_child_environment_profile(event)
13383                });
13384                let event = event.map(|event| if include_session_bundles {
13385                    event
13386                } else {
13387                    project_event_without_session_bundle(event)
13388                });
13389                let event = event.map(|event| {
13390                    project_event_history_for_wire(event, include_history_context_packs)
13391                });
13392                let event = event.map(|event| if include_history_context_packs {
13393                    event
13394                } else {
13395                    project_event_without_context_pack(event)
13396                });
13397                let event = event.map(|event| if include_session_task_correlation {
13398                    event
13399                } else {
13400                    project_event_without_session_task_binding(event)
13401                });
13402                let exact_harness_controller = hello.role == ClientRole::Operator
13403                    && shared.controller_state().is_some_and(|controller| {
13404                        controller.connection_id == connection_id
13405                    });
13406                let event = event.and_then(|event| {
13407                    if include_harness_mcp_proxy && exact_harness_controller {
13408                        Some(event)
13409                    } else {
13410                        project_event_without_harness_mcp_proxy(event)
13411                    }
13412                });
13413                if let Some(event) = event {
13414                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13415                }
13416            }
13417        }
13418        tokio::select! {
13419            biased;
13420            frame = frame_rx.recv() => {
13421                let frame = match frame {
13422                    Some(frame) => frame,
13423                    None => break,
13424                };
13425                let frame = match frame {
13426                    Ok(frame) => frame,
13427                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13428                    Err(error) => return Err(error.into()),
13429                };
13430                let ClientFrame::Request(request) = frame else {
13431                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13432                };
13433                #[cfg(feature = "fixture")]
13434                let probe_spawn_managed_worktree_v2 = matches!(
13435                    &request.request,
13436                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13437                );
13438                let requires_open_provider_ids =
13439                    request_requires_open_provider_ids(&shared, &request.request);
13440                let requires_child_environment_profile =
13441                    request_requires_child_environment_profile(&shared, &request.request);
13442                let requires_session_bundle =
13443                    request_requires_session_bundle(&shared, &request.request);
13444                let requires_history_context_pack =
13445                    request_requires_history_context_pack(&shared, &request.request);
13446                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13447                    ResponseEnvelope {
13448                        request_id: request.request_id,
13449                        result: Err(failure(
13450                            NodeFailureCode::UnsupportedCapability,
13451                            "open provider IDs were not negotiated",
13452                        )),
13453                    }
13454                } else if requires_child_environment_profile
13455                    && !include_child_environment_profiles
13456                {
13457                    ResponseEnvelope {
13458                        request_id: request.request_id,
13459                        result: Err(failure(
13460                            NodeFailureCode::UnsupportedCapability,
13461                            "child environment profiles were not negotiated",
13462                        )),
13463                    }
13464                } else if requires_session_bundle && !include_session_bundles {
13465                    ResponseEnvelope {
13466                        request_id: request.request_id,
13467                        result: Err(failure(
13468                            NodeFailureCode::UnsupportedCapability,
13469                            "session bundles were not negotiated",
13470                        )),
13471                    }
13472                } else if requires_history_context_pack && !include_history_context_packs {
13473                    ResponseEnvelope {
13474                        request_id: request.request_id,
13475                        result: Err(failure(
13476                            NodeFailureCode::UnsupportedCapability,
13477                            "history context packs were not negotiated",
13478                        )),
13479                    }
13480                } else if request_uses_unnegotiated_capability(
13481                    &request.request,
13482                    &selected_capabilities,
13483                )
13484                {
13485                    ResponseEnvelope {
13486                        request_id: request.request_id,
13487                        result: Err(failure(
13488                            NodeFailureCode::UnsupportedCapability,
13489                            "request capability was not negotiated",
13490                        )),
13491                    }
13492                } else {
13493                    process_request(&shared, connection_id, hello.role, request).await
13494                };
13495                #[cfg(feature = "fixture")]
13496                if probe_spawn_managed_worktree_v2 {
13497                    shared
13498                        .fixture_spawn_managed_worktree_v2_failure_probe
13499                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13500                }
13501                if !include_provider_runtime_status {
13502                    clear_response_provider_runtime_status(&mut reply);
13503                }
13504                if !include_managed_worktrees {
13505                    project_response_without_managed_worktrees(&mut reply);
13506                }
13507                if !include_open_provider_ids {
13508                    project_response_legacy_provider_ids(&shared, &mut reply);
13509                }
13510                if !include_child_environment_profiles {
13511                    project_response_without_child_environment_profile(&mut reply);
13512                }
13513                if !include_session_bundles {
13514                    project_response_without_session_bundle(&mut reply);
13515                }
13516                project_response_history_for_wire(
13517                    &mut reply,
13518                    include_history_context_packs,
13519                );
13520                if !include_history_context_packs {
13521                    project_response_without_context_pack(&mut reply);
13522                }
13523                if !include_agent_progress {
13524                    project_response_without_agent_progress(&mut reply);
13525                }
13526                if !include_session_task_correlation {
13527                    project_response_without_session_task_binding(&mut reply);
13528                }
13529                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13530            }
13531            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13532            event = event_rx.recv() => {
13533                match event {
13534                    Ok(event) => queue_connection_event(
13535                        &mut pending_events,
13536                        event,
13537                        discard_events_through,
13538                    ),
13539                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13540                    Err(broadcast::error::RecvError::Closed) => break,
13541                }
13542            }
13543            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13544                match events {
13545                    Ok(events) => queue_connection_event_batch(
13546                        &mut pending_events,
13547                        &events,
13548                        discard_events_through,
13549                    ),
13550                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13551                    Err(broadcast::error::RecvError::Closed) => break,
13552                }
13553            }
13554            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13555                match events {
13556                    Ok(events) => queue_connection_event_batch(
13557                        &mut pending_events,
13558                        &events,
13559                        discard_events_through,
13560                    ),
13561                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13562                    Err(broadcast::error::RecvError::Closed) => break,
13563                }
13564            }
13565            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13566                match events {
13567                    Ok(events) => queue_connection_event_batch(
13568                        &mut pending_events,
13569                        &events,
13570                        discard_events_through,
13571                    ),
13572                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13573                    Err(broadcast::error::RecvError::Closed) => break,
13574                }
13575            }
13576            _ = shared.shutdown_notify.notified() => break,
13577        }
13578    }
13579    Ok(())
13580}
13581
13582async fn receive_terminal_event_batch(
13583    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13584) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13585    match receiver {
13586        Some(receiver) => receiver.recv().await,
13587        None => std::future::pending().await,
13588    }
13589}
13590
13591fn terminal_event_subscription(
13592    shared: &NodeShared,
13593    enabled: bool,
13594) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13595    enabled.then(|| shared.terminal_event_tx.subscribe())
13596}
13597
13598/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13599/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13600/// this needs a dedicated channel rather than a flag on the terminal one.
13601fn agent_stream_event_subscription(
13602    shared: &NodeShared,
13603    enabled: bool,
13604) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13605    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13606}
13607
13608/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13609/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13610/// own doc for why this needs a dedicated channel rather than a ride on
13611/// the durable `event_tx`.
13612fn harness_mcp_proxy_event_subscription(
13613    shared: &NodeShared,
13614    enabled: bool,
13615) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13616    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13617}
13618
13619fn queue_connection_event(
13620    pending: &mut Vec<NodeEventEnvelope>,
13621    event: NodeEventEnvelope,
13622    discard_through: u64,
13623) {
13624    if event.sequence <= discard_through
13625        && !event.event.requires_harness_mcp_proxy_capability()
13626    {
13627        return;
13628    }
13629    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13630        if let Some(index) = pending.iter().position(|current| {
13631            matches!(
13632                &current.event,
13633                NodeEvent::TerminalFrame {
13634                    address: current_address,
13635                    ..
13636                } if current_address == address
13637            )
13638        }) {
13639            pending.remove(index);
13640        }
13641    }
13642    pending.push(event);
13643}
13644
13645fn project_event_without_harness_mcp_proxy(
13646    envelope: NodeEventEnvelope,
13647) -> Option<NodeEventEnvelope> {
13648    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13649}
13650
13651fn queue_connection_event_batch(
13652    pending: &mut Vec<NodeEventEnvelope>,
13653    events: &[NodeEventEnvelope],
13654    discard_through: u64,
13655) {
13656    for event in events {
13657        queue_connection_event(pending, event.clone(), discard_through);
13658    }
13659}
13660
13661fn apply_connection_resync_watermark(
13662    pending: &mut Vec<NodeEventEnvelope>,
13663    discard_through: &mut u64,
13664    marker_sequence: u64,
13665) {
13666    *discard_through = (*discard_through).max(marker_sequence);
13667    pending.retain(|event| event.sequence > *discard_through);
13668}
13669
13670fn connection_event_burst_len(pending_len: usize) -> usize {
13671    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13672}
13673
13674fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13675    let history = shared
13676        .history
13677        .lock()
13678        .unwrap_or_else(|poisoned| poisoned.into_inner());
13679    let sequence = history.last_sequence;
13680    let oldest_available_sequence = history.replay_floor_sequence;
13681    NodeEventEnvelope {
13682        sequence,
13683        event: NodeEvent::ResyncRequired {
13684            oldest_available_sequence,
13685        },
13686    }
13687}
13688
13689fn node_compatibility_support(
13690    shared: &NodeShared,
13691) -> Result<NodeCompatibilitySupport, NodeServerError> {
13692    let mut support = node_compatibility_support_for_manifest(
13693        &shared.provider_contracts,
13694        &shared.provider_adapter_contracts,
13695    )?;
13696    if shared
13697        .delivery_store
13698        .lock()
13699        .unwrap_or_else(|poisoned| poisoned.into_inner())
13700        .is_some()
13701    {
13702        support.capabilities.push(
13703            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13704                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13705        );
13706    }
13707    if shared.harness_mcp_registry.is_some() {
13708        support.capabilities.push(
13709            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13710                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13711        );
13712    }
13713    Ok(support)
13714}
13715
13716fn node_compatibility_support_for_manifest(
13717    provider_contracts: &[ProviderContractSupport],
13718    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13719) -> Result<NodeCompatibilitySupport, NodeServerError> {
13720    Ok(NodeCompatibilitySupport {
13721        build_stamp: BUILD_STAMP.to_owned(),
13722        capabilities: baseline_capabilities()?,
13723        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13724        path_semantics: platform::path_semantics(),
13725        local_transport: platform::local_transport(),
13726        state_schema: StateSchemaSupport {
13727            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13728                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13729        },
13730        provider_contracts: provider_contracts.to_vec(),
13731        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13732    })
13733}
13734
13735fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13736    let capabilities = [
13737        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13738        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13739        NODE_REPOSITORY_PATH_CAPABILITY,
13740        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13741        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13742        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13743        NODE_GIT_READ_CAPABILITY,
13744        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13745        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13746        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13747        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13748        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13749        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13750        NODE_ACP_CONTROL_CAPABILITY,
13751        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13752        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13753        NODE_WORKTREE_SELECTION_CAPABILITY,
13754        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13755        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13756        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13757        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13758        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13759        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13760        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13761        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13762        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13763        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13764        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13765        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13766    ]
13767        .into_iter()
13768        .map(|capability| {
13769            CapabilityId::new(capability)
13770                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13771        })
13772        .collect::<Result<Vec<_>, _>>()?;
13773    #[cfg(windows)]
13774    let capabilities = {
13775        let mut capabilities = capabilities;
13776        capabilities.push(
13777            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13778                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13779        );
13780        capabilities
13781    };
13782    Ok(capabilities)
13783}
13784
13785fn request_uses_unnegotiated_capability(
13786    request: &NodeRequest,
13787    selected_capabilities: &[CapabilityId],
13788) -> bool {
13789    let selected = |required: &str| {
13790        selected_capabilities
13791            .iter()
13792            .any(|capability| capability.as_str() == required)
13793    };
13794    request
13795        .required_capability()
13796        .is_some_and(|required| !selected(required))
13797        || (request.requires_worktree_selection_capability()
13798            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13799        || (request.requires_spawn_spec_defaults_overrides_capability()
13800            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13801        || (request.requires_spawn_profile_revision_capability()
13802            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13803        || (request.requires_child_environment_profile_capability()
13804            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13805        || (request.requires_session_bundle_materialization_capability()
13806            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13807        || (request.requires_history_context_pack_capability()
13808            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13809}
13810
13811fn snapshot_for_wire(
13812    shared: &NodeShared,
13813    include_provider_runtime_status: bool,
13814    include_open_provider_ids: bool,
13815    include_spawn_profiles: bool,
13816    include_managed_worktrees: bool,
13817    include_child_environment_profiles: bool,
13818    include_session_bundles: bool,
13819    include_history_context_packs: bool,
13820    include_agent_progress: bool,
13821    include_session_task_correlation: bool,
13822) -> NodeSnapshot {
13823    let mut snapshot = shared.snapshot();
13824    if !include_provider_runtime_status {
13825        snapshot.provider_runtime_statuses.clear();
13826    }
13827    if !include_open_provider_ids {
13828        project_snapshot_legacy_provider_ids(&mut snapshot);
13829    }
13830    if !include_managed_worktrees {
13831        snapshot.managed_worktrees.clear();
13832        for workspace in &mut snapshot.workspaces {
13833            workspace.managed_worktree_profiles = None;
13834        }
13835    }
13836    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13837        if !include_spawn_profiles {
13838            inventory.spawn_profiles = None;
13839        }
13840        if !include_session_bundles {
13841            inventory.bundles = None;
13842        }
13843        if inventory.spawn_profiles.is_none()
13844            && inventory.bundles.is_none()
13845            && inventory.network_allowlists.is_none()
13846        {
13847            snapshot.launch_inventory = None;
13848        }
13849    }
13850    if !include_child_environment_profiles {
13851        clear_snapshot_child_environment_profiles(&mut snapshot);
13852    }
13853    if !include_session_bundles {
13854        clear_snapshot_session_bundles(&mut snapshot);
13855    }
13856    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13857    if !include_history_context_packs {
13858        clear_snapshot_context_packs(&mut snapshot);
13859    }
13860    if !include_agent_progress {
13861        snapshot.agent_progress.clear();
13862    }
13863    if !include_session_task_correlation {
13864        clear_snapshot_session_task_bindings(&mut snapshot);
13865    }
13866    snapshot
13867}
13868
13869fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13870    for record in &mut snapshot.session_records {
13871        record.task_binding = None;
13872    }
13873}
13874
13875fn project_event_without_session_task_binding(
13876    mut envelope: NodeEventEnvelope,
13877) -> NodeEventEnvelope {
13878    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13879        record.task_binding = None;
13880    }
13881    envelope
13882}
13883
13884fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13885    let Ok(response) = reply.result.as_mut() else { return };
13886    match response {
13887        NodeResponse::Snapshot { snapshot, .. } => {
13888            clear_snapshot_session_task_bindings(snapshot);
13889        }
13890        NodeResponse::Resync { snapshot, events, .. } => {
13891            clear_snapshot_session_task_bindings(snapshot);
13892            for event in events {
13893                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13894                    record.task_binding = None;
13895                }
13896            }
13897        }
13898        NodeResponse::SessionRecordUpdated { record }
13899        | NodeResponse::ProviderSessionIndexed { record }
13900        | NodeResponse::NativeSessionIndexed { record, .. }
13901        | NodeResponse::SessionRecordResumed { record, .. } => {
13902            record.task_binding = None;
13903        }
13904        _ => {}
13905    }
13906}
13907
13908fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13909    let Ok(response) = reply.result.as_mut() else { return };
13910    match response {
13911        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13912            snapshot.agent_progress.clear();
13913        }
13914        _ => {}
13915    }
13916}
13917
13918fn project_event_without_managed_worktrees(
13919    mut envelope: NodeEventEnvelope,
13920) -> Option<NodeEventEnvelope> {
13921    if matches!(
13922        envelope.event,
13923        NodeEvent::ManagedWorktreeUpserted { .. }
13924            | NodeEvent::ManagedWorktreeRemoved { .. }
13925    ) {
13926        return None;
13927    }
13928    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13929        workspace.managed_worktree_profiles = None;
13930    }
13931    Some(envelope)
13932}
13933
13934fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13935    if matches!(
13936        reply.result,
13937        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13938            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13939    ) {
13940        reply.result = Err(failure(
13941            NodeFailureCode::UnsupportedCapability,
13942            "managed worktree capability was not negotiated",
13943        ));
13944        return;
13945    }
13946    let Ok(response) = reply.result.as_mut() else { return };
13947    match response {
13948        NodeResponse::Snapshot { snapshot, .. } => {
13949            snapshot.managed_worktrees.clear();
13950            for workspace in &mut snapshot.workspaces {
13951                workspace.managed_worktree_profiles = None;
13952            }
13953        }
13954        NodeResponse::Resync { snapshot, events, .. } => {
13955            snapshot.managed_worktrees.clear();
13956            for workspace in &mut snapshot.workspaces {
13957                workspace.managed_worktree_profiles = None;
13958            }
13959            *events = std::mem::take(events)
13960                .into_iter()
13961                .filter_map(project_event_without_managed_worktrees)
13962                .collect();
13963        }
13964        NodeResponse::WorkspaceRegistered { workspace }
13965        | NodeResponse::StandaloneWorkspaceCreated { workspace }
13966        | NodeResponse::WorktreeCreated { workspace, .. } => {
13967            workspace.managed_worktree_profiles = None;
13968        }
13969        NodeResponse::WorkspaceInspected { inspection } => {
13970            inspection.git.managed_worktree = None;
13971        }
13972        _ => {}
13973    }
13974}
13975
13976fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13977    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13978        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13979    });
13980    if includes_open_provider_ids {
13981        return;
13982    }
13983    compatibility
13984        .provider_contracts
13985        .retain(|contract| provider_id_is_legacy(&contract.provider));
13986    compatibility
13987        .provider_adapter_contracts
13988        .retain(|contract| provider_id_is_legacy(&contract.provider));
13989}
13990
13991fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13992    if let Some(spec) = match request {
13993        NodeRequest::SpawnSpec { spec }
13994        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13995        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13996        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13997        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13998        _ => None,
13999    } {
14000        return shared
14001            .resolve_spawn_spec(spec)
14002            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14003    }
14004    request_requires_open_provider_ids_with(
14005        request,
14006        |session| shared.validate_address(session).ok(),
14007        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14008    )
14009}
14010
14011fn request_requires_child_environment_profile(
14012    shared: &NodeShared,
14013    request: &NodeRequest,
14014) -> bool {
14015    if let Some(spec) = match request {
14016        NodeRequest::SpawnSpec { spec }
14017        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14018        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14019        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14020        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14021        _ => None,
14022    } {
14023        return shared
14024            .resolve_spawn_spec(spec)
14025            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14026    }
14027    match request {
14028        NodeRequest::Resume { session, .. }
14029        | NodeRequest::Prompt { session, .. }
14030        | NodeRequest::Paste { session, .. }
14031        | NodeRequest::Input { session, .. }
14032        | NodeRequest::TerminalBytes { session, .. }
14033        | NodeRequest::TerminalControl { session, .. }
14034        | NodeRequest::Resize { session, .. }
14035        | NodeRequest::Interrupt { session }
14036        | NodeRequest::Stop { session, .. }
14037        | NodeRequest::Remove { session }
14038        | NodeRequest::ResolveInteraction { session, .. }
14039        | NodeRequest::SetSessionMode { session, .. }
14040        | NodeRequest::SetSessionConfigOption { session, .. }
14041        | NodeRequest::SetSessionModel { session, .. }
14042        | NodeRequest::DiscoverHistory { session, .. }
14043        | NodeRequest::LoadHistory { session, .. }
14044        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14045        | NodeRequest::ExportContextPack { session } => shared
14046            .session_bindings
14047            .lock()
14048            .unwrap_or_else(|poisoned| poisoned.into_inner())
14049            .get(&session.session.instance_id)
14050            .filter(|binding| {
14051                binding.workspace_id == session.workspace_id
14052                    && binding.generation == session.session.generation
14053            })
14054            .map_or(true, |binding| binding.environment_profile.is_some()),
14055        NodeRequest::RenameSessionRecord { record_id, .. }
14056        | NodeRequest::SetSessionTask { record_id, .. }
14057        | NodeRequest::ResumeSessionRecord { record_id, .. }
14058        | NodeRequest::ForgetSessionRecord { record_id } => shared
14059            .record(record_id)
14060            .map_or(true, |record| record.environment_profile.is_some()),
14061        NodeRequest::ArmHarnessMcpReservation { .. }
14062        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14063        | NodeRequest::ActivateHarnessMcpReservation { .. }
14064        | NodeRequest::AbortHarnessMcpReservation { .. }
14065        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14066        | NodeRequest::RejectHarnessMcpCall { .. }
14067        | NodeRequest::Snapshot
14068        | NodeRequest::Resync { .. }
14069        | NodeRequest::BeginDeliveryStage { .. }
14070        | NodeRequest::PutDeliveryBlobChunk { .. }
14071        | NodeRequest::CommitDeliveryStage { .. }
14072        | NodeRequest::AbortDeliveryStage { .. }
14073        | NodeRequest::BrowseHostDirectories { .. }
14074        | NodeRequest::InspectWorkspace { .. }
14075        | NodeRequest::ReadWorkspaceFile { .. }
14076        | NodeRequest::WriteWorkspaceFile { .. }
14077        | NodeRequest::CreateWorkspaceFile { .. }
14078        | NodeRequest::CreateWorkspaceDirectory { .. }
14079        | NodeRequest::ReadGitHistory { .. }
14080        | NodeRequest::ReadGitDiff { .. }
14081        | NodeRequest::AcquireController { .. }
14082        | NodeRequest::ReleaseController
14083        | NodeRequest::Spawn { .. }
14084        | NodeRequest::SpawnSpec { .. }
14085        | NodeRequest::RegisterWorkspace { .. }
14086        | NodeRequest::CreateStandaloneWorkspace { .. }
14087        | NodeRequest::UnregisterWorkspace { .. }
14088        | NodeRequest::CreateWorktree { .. }
14089        | NodeRequest::RemoveWorktree { .. }
14090        | NodeRequest::SpawnManagedWorktree { .. }
14091        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14092        | NodeRequest::CleanupManagedWorktree { .. }
14093        | NodeRequest::IndexProviderSession { .. }
14094        | NodeRequest::IndexNativeSession { .. }
14095        | NodeRequest::CatalogNativeSessions { .. }
14096        | NodeRequest::PageNativeSessions { .. }
14097        | NodeRequest::PreviewNativeSession { .. }
14098        | NodeRequest::PreviewSessionRecord { .. }
14099        | NodeRequest::ForgetContextPack { .. }
14100        | NodeRequest::ResolveDurableContextPack { .. }
14101        | NodeRequest::ReadContextPack { .. }
14102        | NodeRequest::Shutdown => false,
14103    }
14104}
14105
14106fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14107    if let Some(spec) = match request {
14108        NodeRequest::SpawnSpec { spec }
14109        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14110        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14111        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14112        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14113        _ => None,
14114    } {
14115        return shared
14116            .resolve_spawn_spec(spec)
14117            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14118    }
14119    match request {
14120        NodeRequest::Resume { session, .. }
14121        | NodeRequest::Prompt { session, .. }
14122        | NodeRequest::Paste { session, .. }
14123        | NodeRequest::Input { session, .. }
14124        | NodeRequest::TerminalBytes { session, .. }
14125        | NodeRequest::TerminalControl { session, .. }
14126        | NodeRequest::Resize { session, .. }
14127        | NodeRequest::Interrupt { session }
14128        | NodeRequest::Stop { session, .. }
14129        | NodeRequest::Remove { session }
14130        | NodeRequest::ResolveInteraction { session, .. }
14131        | NodeRequest::SetSessionMode { session, .. }
14132        | NodeRequest::SetSessionConfigOption { session, .. }
14133        | NodeRequest::SetSessionModel { session, .. }
14134        | NodeRequest::DiscoverHistory { session, .. }
14135        | NodeRequest::LoadHistory { session, .. }
14136        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14137        | NodeRequest::ExportContextPack { session } => shared
14138            .session_bindings
14139            .lock()
14140            .unwrap_or_else(|poisoned| poisoned.into_inner())
14141            .get(&session.session.instance_id)
14142            .filter(|binding| {
14143                binding.workspace_id == session.workspace_id
14144                    && binding.generation == session.session.generation
14145            })
14146            .map_or(true, |binding| binding.bundle.is_some()),
14147        NodeRequest::RenameSessionRecord { record_id, .. }
14148        | NodeRequest::SetSessionTask { record_id, .. }
14149        | NodeRequest::ResumeSessionRecord { record_id, .. }
14150        | NodeRequest::ForgetSessionRecord { record_id } => shared
14151            .record(record_id)
14152            .map_or(true, |record| record.bundle.is_some()),
14153        NodeRequest::ArmHarnessMcpReservation { .. }
14154        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14155        | NodeRequest::ActivateHarnessMcpReservation { .. }
14156        | NodeRequest::AbortHarnessMcpReservation { .. }
14157        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14158        | NodeRequest::RejectHarnessMcpCall { .. }
14159        | NodeRequest::Snapshot
14160        | NodeRequest::Resync { .. }
14161        | NodeRequest::BeginDeliveryStage { .. }
14162        | NodeRequest::PutDeliveryBlobChunk { .. }
14163        | NodeRequest::CommitDeliveryStage { .. }
14164        | NodeRequest::AbortDeliveryStage { .. }
14165        | NodeRequest::BrowseHostDirectories { .. }
14166        | NodeRequest::InspectWorkspace { .. }
14167        | NodeRequest::ReadWorkspaceFile { .. }
14168        | NodeRequest::WriteWorkspaceFile { .. }
14169        | NodeRequest::CreateWorkspaceFile { .. }
14170        | NodeRequest::CreateWorkspaceDirectory { .. }
14171        | NodeRequest::ReadGitHistory { .. }
14172        | NodeRequest::ReadGitDiff { .. }
14173        | NodeRequest::AcquireController { .. }
14174        | NodeRequest::ReleaseController
14175        | NodeRequest::Spawn { .. }
14176        | NodeRequest::SpawnSpec { .. }
14177        | NodeRequest::RegisterWorkspace { .. }
14178        | NodeRequest::CreateStandaloneWorkspace { .. }
14179        | NodeRequest::UnregisterWorkspace { .. }
14180        | NodeRequest::CreateWorktree { .. }
14181        | NodeRequest::RemoveWorktree { .. }
14182        | NodeRequest::SpawnManagedWorktree { .. }
14183        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14184        | NodeRequest::CleanupManagedWorktree { .. }
14185        | NodeRequest::IndexProviderSession { .. }
14186        | NodeRequest::IndexNativeSession { .. }
14187        | NodeRequest::CatalogNativeSessions { .. }
14188        | NodeRequest::PageNativeSessions { .. }
14189        | NodeRequest::PreviewNativeSession { .. }
14190        | NodeRequest::PreviewSessionRecord { .. }
14191        | NodeRequest::ForgetContextPack { .. }
14192        | NodeRequest::ResolveDurableContextPack { .. }
14193        | NodeRequest::ReadContextPack { .. }
14194        | NodeRequest::Shutdown => false,
14195    }
14196}
14197
14198fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14199    if request.requires_history_context_pack_capability() {
14200        return true;
14201    }
14202    if let Some(spec) = match request {
14203        NodeRequest::SpawnSpec { spec }
14204        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14205        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14206        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14207        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14208        _ => None,
14209    } {
14210        return shared
14211            .resolve_spawn_spec(spec)
14212            .map_or(true, |resolved| resolved.context_id.is_some());
14213    }
14214    match request {
14215        NodeRequest::Resume { session, .. }
14216        | NodeRequest::Prompt { session, .. }
14217        | NodeRequest::Paste { session, .. }
14218        | NodeRequest::Input { session, .. }
14219        | NodeRequest::TerminalBytes { session, .. }
14220        | NodeRequest::TerminalControl { session, .. }
14221        | NodeRequest::Resize { session, .. }
14222        | NodeRequest::Interrupt { session }
14223        | NodeRequest::Stop { session, .. }
14224        | NodeRequest::Remove { session }
14225        | NodeRequest::ResolveInteraction { session, .. }
14226        | NodeRequest::SetSessionMode { session, .. }
14227        | NodeRequest::SetSessionConfigOption { session, .. }
14228        | NodeRequest::SetSessionModel { session, .. } => shared
14229            .session_bindings
14230            .lock()
14231            .unwrap_or_else(|poisoned| poisoned.into_inner())
14232            .get(&session.session.instance_id)
14233            .filter(|binding| {
14234                binding.workspace_id == session.workspace_id
14235                    && binding.generation == session.session.generation
14236            })
14237            .map_or(true, |binding| binding.context.is_some()),
14238        NodeRequest::RenameSessionRecord { record_id, .. }
14239        | NodeRequest::SetSessionTask { record_id, .. }
14240        | NodeRequest::ResumeSessionRecord { record_id, .. }
14241        | NodeRequest::ForgetSessionRecord { record_id } => shared
14242            .record(record_id)
14243            .map_or(true, |record| record.context.is_some()),
14244        NodeRequest::ArmHarnessMcpReservation { .. }
14245        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14246        | NodeRequest::ActivateHarnessMcpReservation { .. }
14247        | NodeRequest::AbortHarnessMcpReservation { .. }
14248        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14249        | NodeRequest::RejectHarnessMcpCall { .. }
14250        | NodeRequest::Snapshot
14251        | NodeRequest::Resync { .. }
14252        | NodeRequest::BeginDeliveryStage { .. }
14253        | NodeRequest::PutDeliveryBlobChunk { .. }
14254        | NodeRequest::CommitDeliveryStage { .. }
14255        | NodeRequest::AbortDeliveryStage { .. }
14256        | NodeRequest::BrowseHostDirectories { .. }
14257        | NodeRequest::InspectWorkspace { .. }
14258        | NodeRequest::ReadWorkspaceFile { .. }
14259        | NodeRequest::WriteWorkspaceFile { .. }
14260        | NodeRequest::CreateWorkspaceFile { .. }
14261        | NodeRequest::CreateWorkspaceDirectory { .. }
14262        | NodeRequest::ReadGitHistory { .. }
14263        | NodeRequest::ReadGitDiff { .. }
14264        | NodeRequest::AcquireController { .. }
14265        | NodeRequest::ReleaseController
14266        | NodeRequest::Spawn { .. }
14267        | NodeRequest::SpawnSpec { .. }
14268        | NodeRequest::RegisterWorkspace { .. }
14269        | NodeRequest::CreateStandaloneWorkspace { .. }
14270        | NodeRequest::UnregisterWorkspace { .. }
14271        | NodeRequest::CreateWorktree { .. }
14272        | NodeRequest::RemoveWorktree { .. }
14273        | NodeRequest::SpawnManagedWorktree { .. }
14274        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14275        | NodeRequest::CleanupManagedWorktree { .. }
14276        | NodeRequest::IndexProviderSession { .. }
14277        | NodeRequest::IndexNativeSession { .. }
14278        | NodeRequest::CatalogNativeSessions { .. }
14279        | NodeRequest::PageNativeSessions { .. }
14280        | NodeRequest::PreviewNativeSession { .. }
14281        | NodeRequest::PreviewSessionRecord { .. }
14282        | NodeRequest::DiscoverHistory { .. }
14283        | NodeRequest::LoadHistory { .. }
14284        | NodeRequest::ExportContextPackForSessionRecord { .. }
14285        | NodeRequest::ExportContextPack { .. }
14286        | NodeRequest::ForgetContextPack { .. }
14287        | NodeRequest::ResolveDurableContextPack { .. }
14288        | NodeRequest::ReadContextPack { .. }
14289        | NodeRequest::Shutdown => false,
14290    }
14291}
14292
14293fn request_requires_open_provider_ids_with(
14294    request: &NodeRequest,
14295    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14296    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14297) -> bool {
14298    match request {
14299        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14300        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14301        NodeRequest::CatalogNativeSessions { route, .. }
14302        | NodeRequest::PageNativeSessions { route, .. } => {
14303            !provider_id_is_legacy(&route.provider)
14304        }
14305        NodeRequest::PreviewNativeSession { selection, .. }
14306        | NodeRequest::IndexNativeSession { selection, .. } => {
14307            !provider_id_is_legacy(&selection.route.provider)
14308        }
14309        NodeRequest::SpawnSpec { .. }
14310        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14311        NodeRequest::SpawnManagedWorktree { .. }
14312        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14313        NodeRequest::Resume { session, .. }
14314        | NodeRequest::Prompt { session, .. }
14315        | NodeRequest::Paste { session, .. }
14316        | NodeRequest::Input { session, .. }
14317        | NodeRequest::TerminalBytes { session, .. }
14318        | NodeRequest::TerminalControl { session, .. }
14319        | NodeRequest::Resize { session, .. }
14320        | NodeRequest::Interrupt { session }
14321        | NodeRequest::Stop { session, .. }
14322        | NodeRequest::Remove { session }
14323        | NodeRequest::ResolveInteraction { session, .. }
14324        | NodeRequest::SetSessionMode { session, .. }
14325        | NodeRequest::SetSessionConfigOption { session, .. }
14326        | NodeRequest::SetSessionModel { session, .. }
14327        | NodeRequest::DiscoverHistory { session, .. }
14328        | NodeRequest::LoadHistory { session, .. }
14329        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14330        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14331            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14332        NodeRequest::RenameSessionRecord { record_id, .. }
14333        | NodeRequest::SetSessionTask { record_id, .. }
14334        | NodeRequest::ResumeSessionRecord { record_id, .. }
14335        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14336            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14337        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14338            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14339        NodeRequest::ArmHarnessMcpReservation { .. }
14340        | NodeRequest::ActivateHarnessMcpReservation { .. }
14341        | NodeRequest::AbortHarnessMcpReservation { .. }
14342        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14343        | NodeRequest::RejectHarnessMcpCall { .. }
14344        | NodeRequest::Snapshot
14345        | NodeRequest::Resync { .. }
14346        | NodeRequest::BeginDeliveryStage { .. }
14347        | NodeRequest::PutDeliveryBlobChunk { .. }
14348        | NodeRequest::CommitDeliveryStage { .. }
14349        | NodeRequest::AbortDeliveryStage { .. }
14350        | NodeRequest::BrowseHostDirectories { .. }
14351        | NodeRequest::InspectWorkspace { .. }
14352        | NodeRequest::ReadWorkspaceFile { .. }
14353        | NodeRequest::WriteWorkspaceFile { .. }
14354        | NodeRequest::CreateWorkspaceFile { .. }
14355        | NodeRequest::CreateWorkspaceDirectory { .. }
14356        | NodeRequest::ReadGitHistory { .. }
14357        | NodeRequest::ReadGitDiff { .. }
14358        | NodeRequest::AcquireController { .. }
14359        | NodeRequest::ReleaseController
14360        | NodeRequest::RegisterWorkspace { .. }
14361        | NodeRequest::CreateStandaloneWorkspace { .. }
14362        | NodeRequest::UnregisterWorkspace { .. }
14363        | NodeRequest::CreateWorktree { .. }
14364        | NodeRequest::RemoveWorktree { .. }
14365        | NodeRequest::CleanupManagedWorktree { .. }
14366        | NodeRequest::Shutdown => false,
14367        NodeRequest::ForgetContextPack { .. } => true,
14368        // No session/record to cheaply resolve a provider from here either;
14369        // the resolved receipt can carry an open-id provider, so require the
14370        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14371        // is the same digest-keyed lookup one step further into the same
14372        // catalog, so it inherits the same conservative answer.
14373        NodeRequest::ResolveDurableContextPack { .. }
14374        | NodeRequest::ReadContextPack { .. } => true,
14375    }
14376}
14377
14378fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14379    snapshot.enabled_providers.retain(provider_id_is_legacy);
14380    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14381        snapshot
14382            .provider_runtime_statuses
14383            .as_slice()
14384            .iter()
14385            .filter(|status| provider_id_is_legacy(status.provider()))
14386            .cloned(),
14387    )
14388    .expect("a filtered provider runtime projection remains valid");
14389    snapshot
14390        .session_records
14391        .retain(|record| provider_id_is_legacy(&record.provider));
14392    for workspace in &mut snapshot.workspaces {
14393        workspace
14394            .sessions
14395            .retain(|session| provider_id_is_legacy(&session.agent_id));
14396    }
14397}
14398
14399fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14400    for record in &mut snapshot.session_records {
14401        record.environment_profile = None;
14402    }
14403    if let Some(profiles) = snapshot
14404        .launch_inventory
14405        .as_mut()
14406        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14407    {
14408        for profile in profiles {
14409            profile.environment_profile = None;
14410        }
14411    }
14412}
14413
14414fn project_event_without_child_environment_profile(
14415    mut envelope: NodeEventEnvelope,
14416) -> NodeEventEnvelope {
14417    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14418        record.environment_profile = None;
14419    }
14420    envelope
14421}
14422
14423fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14424    let contains_environment_profile = match reply.result.as_ref() {
14425        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14426            receipt.environment_profile.is_some()
14427        }
14428        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14429            receipt.spawn.environment_profile.is_some()
14430        }
14431        _ => false,
14432    };
14433    if contains_environment_profile {
14434        reply.result = Err(failure(
14435            NodeFailureCode::UnsupportedCapability,
14436            "child environment profiles were not negotiated",
14437        ));
14438        return;
14439    }
14440    let Ok(response) = reply.result.as_mut() else {
14441        return;
14442    };
14443    match response {
14444        NodeResponse::Snapshot { snapshot, .. } => {
14445            clear_snapshot_child_environment_profiles(snapshot);
14446        }
14447        NodeResponse::Resync { snapshot, events, .. } => {
14448            clear_snapshot_child_environment_profiles(snapshot);
14449            for event in events {
14450                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14451                    record.environment_profile = None;
14452                }
14453            }
14454        }
14455        NodeResponse::SessionRecordUpdated { record }
14456        | NodeResponse::ProviderSessionIndexed { record }
14457        | NodeResponse::NativeSessionIndexed { record, .. }
14458        | NodeResponse::SessionRecordResumed { record, .. } => {
14459            record.environment_profile = None;
14460        }
14461        NodeResponse::Armed { .. }
14462        | NodeResponse::Spawned { .. }
14463        | NodeResponse::Activated { .. }
14464        | NodeResponse::Aborted { .. }
14465        | NodeResponse::ReplyChunkAccepted { .. }
14466        | NodeResponse::CallRejected { .. }
14467        | NodeResponse::DeliveryStageBegun { .. }
14468        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14469        | NodeResponse::DeliveryCommitted { .. }
14470        | NodeResponse::DeliveryStageAborted { .. }
14471        | NodeResponse::WorkspaceInspected { .. }
14472        | NodeResponse::HostDirectoriesBrowsed { .. }
14473        | NodeResponse::WorkspaceFileRead { .. }
14474        | NodeResponse::WorkspaceFileWritten { .. }
14475        | NodeResponse::WorkspaceFileCreated { .. }
14476        | NodeResponse::WorkspaceDirectoryCreated { .. }
14477        | NodeResponse::GitHistoryRead { .. }
14478        | NodeResponse::GitDiffRead { .. }
14479        | NodeResponse::Controller { .. }
14480        | NodeResponse::SpawnAccepted { .. }
14481        | NodeResponse::SpawnSpecAccepted { .. }
14482        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14483        | NodeResponse::ManagedWorktreeCleanup { .. }
14484        | NodeResponse::NativeSessionsCataloged { .. }
14485        | NodeResponse::NativeSessionsPaged { .. }
14486        | NodeResponse::NativeSessionPreviewed { .. }
14487        | NodeResponse::SessionRecordPreviewed { .. }
14488        | NodeResponse::HistoryDiscovered { .. }
14489        | NodeResponse::HistoryLoaded { .. }
14490        | NodeResponse::ContextPackForSessionRecordExported { .. }
14491        | NodeResponse::ContextPackExported { .. }
14492        | NodeResponse::ContextPackForgotten { .. }
14493        | NodeResponse::DurableContextPackResolved { .. }
14494        | NodeResponse::ContextPackBytesRead { .. }
14495        | NodeResponse::SessionRecordForgotten { .. }
14496        | NodeResponse::WorkspaceRegistered { .. }
14497        | NodeResponse::StandaloneWorkspaceCreated { .. }
14498        | NodeResponse::WorkspaceUnregistered { .. }
14499        | NodeResponse::WorktreeCreated { .. }
14500        | NodeResponse::WorktreeRemoved { .. }
14501        | NodeResponse::Accepted
14502        | NodeResponse::ShuttingDown => {}
14503    }
14504}
14505
14506fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14507    for record in &mut snapshot.session_records {
14508        record.bundle = None;
14509    }
14510}
14511
14512fn project_event_without_session_bundle(
14513    mut envelope: NodeEventEnvelope,
14514) -> NodeEventEnvelope {
14515    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14516        record.bundle = None;
14517    }
14518    envelope
14519}
14520
14521fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14522    let contains_bundle = match reply.result.as_ref() {
14523        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14524        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14525            receipt.spawn.bundle.is_some()
14526        }
14527        _ => false,
14528    };
14529    if contains_bundle {
14530        reply.result = Err(failure(
14531            NodeFailureCode::UnsupportedCapability,
14532            "session bundles were not negotiated",
14533        ));
14534        return;
14535    }
14536    let Ok(response) = reply.result.as_mut() else {
14537        return;
14538    };
14539    match response {
14540        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14541        NodeResponse::Resync { snapshot, events, .. } => {
14542            clear_snapshot_session_bundles(snapshot);
14543            for event in events {
14544                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14545                    record.bundle = None;
14546                }
14547            }
14548        }
14549        NodeResponse::SessionRecordUpdated { record }
14550        | NodeResponse::ProviderSessionIndexed { record }
14551        | NodeResponse::NativeSessionIndexed { record, .. }
14552        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14553        NodeResponse::Armed { .. }
14554        | NodeResponse::Spawned { .. }
14555        | NodeResponse::Activated { .. }
14556        | NodeResponse::Aborted { .. }
14557        | NodeResponse::ReplyChunkAccepted { .. }
14558        | NodeResponse::CallRejected { .. }
14559        | NodeResponse::DeliveryStageBegun { .. }
14560        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14561        | NodeResponse::DeliveryCommitted { .. }
14562        | NodeResponse::DeliveryStageAborted { .. }
14563        | NodeResponse::WorkspaceInspected { .. }
14564        | NodeResponse::HostDirectoriesBrowsed { .. }
14565        | NodeResponse::WorkspaceFileRead { .. }
14566        | NodeResponse::WorkspaceFileWritten { .. }
14567        | NodeResponse::WorkspaceFileCreated { .. }
14568        | NodeResponse::WorkspaceDirectoryCreated { .. }
14569        | NodeResponse::GitHistoryRead { .. }
14570        | NodeResponse::GitDiffRead { .. }
14571        | NodeResponse::Controller { .. }
14572        | NodeResponse::SpawnAccepted { .. }
14573        | NodeResponse::SpawnSpecAccepted { .. }
14574        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14575        | NodeResponse::ManagedWorktreeCleanup { .. }
14576        | NodeResponse::NativeSessionsCataloged { .. }
14577        | NodeResponse::NativeSessionsPaged { .. }
14578        | NodeResponse::NativeSessionPreviewed { .. }
14579        | NodeResponse::SessionRecordPreviewed { .. }
14580        | NodeResponse::HistoryDiscovered { .. }
14581        | NodeResponse::HistoryLoaded { .. }
14582        | NodeResponse::ContextPackForSessionRecordExported { .. }
14583        | NodeResponse::ContextPackExported { .. }
14584        | NodeResponse::ContextPackForgotten { .. }
14585        | NodeResponse::DurableContextPackResolved { .. }
14586        | NodeResponse::ContextPackBytesRead { .. }
14587        | NodeResponse::SessionRecordForgotten { .. }
14588        | NodeResponse::WorkspaceRegistered { .. }
14589        | NodeResponse::StandaloneWorkspaceCreated { .. }
14590        | NodeResponse::WorkspaceUnregistered { .. }
14591        | NodeResponse::WorktreeCreated { .. }
14592        | NodeResponse::WorktreeRemoved { .. }
14593        | NodeResponse::Accepted
14594        | NodeResponse::ShuttingDown => {}
14595    }
14596}
14597
14598fn project_snapshot_history_for_wire(
14599    snapshot: &mut NodeSnapshot,
14600    include_history_metadata: bool,
14601) {
14602    for workspace in &mut snapshot.workspaces {
14603        for session in &mut workspace.sessions {
14604            if include_history_metadata {
14605                session.history.loaded = None;
14606            } else {
14607                session.history = Default::default();
14608            }
14609        }
14610    }
14611}
14612
14613fn project_event_history_for_wire(
14614    mut envelope: NodeEventEnvelope,
14615    include_history_metadata: bool,
14616) -> NodeEventEnvelope {
14617    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14618        for session in &mut workspace.sessions {
14619            if include_history_metadata {
14620                session.history.loaded = None;
14621            } else {
14622                session.history = Default::default();
14623            }
14624        }
14625    }
14626    envelope
14627}
14628
14629fn project_response_history_for_wire(
14630    reply: &mut ResponseEnvelope,
14631    include_history_metadata: bool,
14632) {
14633    let Ok(response) = reply.result.as_mut() else {
14634        return;
14635    };
14636    match response {
14637        NodeResponse::Snapshot { snapshot, .. } => {
14638            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14639        }
14640        NodeResponse::Resync {
14641            snapshot, events, ..
14642        } => {
14643            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14644            for event in events {
14645                *event = project_event_history_for_wire(
14646                    event.clone(),
14647                    include_history_metadata,
14648                );
14649            }
14650        }
14651        NodeResponse::WorkspaceRegistered { workspace }
14652        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14653        | NodeResponse::WorktreeCreated { workspace, .. } => {
14654            for session in &mut workspace.sessions {
14655                if include_history_metadata {
14656                    session.history.loaded = None;
14657                } else {
14658                    session.history = Default::default();
14659                }
14660            }
14661        }
14662        _ => {}
14663    }
14664}
14665
14666fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14667    for record in &mut snapshot.session_records {
14668        record.context_id = None;
14669        record.context = None;
14670        record.exported_context = None;
14671    }
14672}
14673
14674fn project_event_without_context_pack(
14675    mut envelope: NodeEventEnvelope,
14676) -> NodeEventEnvelope {
14677    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14678        record.context_id = None;
14679        record.context = None;
14680        record.exported_context = None;
14681    }
14682    envelope
14683}
14684
14685fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14686    let contains_context = match reply.result.as_ref() {
14687        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14688        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14689            receipt.spawn.context.is_some()
14690        }
14691        Ok(NodeResponse::HistoryDiscovered { .. })
14692        | Ok(NodeResponse::HistoryLoaded { .. })
14693        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14694        | Ok(NodeResponse::ContextPackExported { .. })
14695        | Ok(NodeResponse::ContextPackForgotten { .. })
14696        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14697        _ => false,
14698    };
14699    if contains_context {
14700        reply.result = Err(failure(
14701            NodeFailureCode::UnsupportedCapability,
14702            "history context packs were not negotiated",
14703        ));
14704        return;
14705    }
14706    let Ok(response) = reply.result.as_mut() else {
14707        return;
14708    };
14709    match response {
14710        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14711        NodeResponse::Resync {
14712            snapshot, events, ..
14713        } => {
14714            clear_snapshot_context_packs(snapshot);
14715            for event in events {
14716                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14717                    record.context_id = None;
14718                    record.context = None;
14719                    record.exported_context = None;
14720                }
14721            }
14722        }
14723        NodeResponse::SessionRecordUpdated { record }
14724        | NodeResponse::ProviderSessionIndexed { record }
14725        | NodeResponse::NativeSessionIndexed { record, .. }
14726        | NodeResponse::SessionRecordResumed { record, .. } => {
14727            record.context_id = None;
14728            record.context = None;
14729            record.exported_context = None;
14730        }
14731        _ => {}
14732    }
14733}
14734
14735fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14736    let contains_open_record = match reply.result.as_ref() {
14737        Ok(NodeResponse::SessionRecordUpdated { record })
14738        | Ok(NodeResponse::ProviderSessionIndexed { record })
14739        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14740            !provider_id_is_legacy(&record.provider)
14741        }
14742        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14743            !provider_id_is_legacy(&selection.route.provider)
14744                || !provider_id_is_legacy(&record.provider)
14745        }
14746        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14747            !provider_id_is_legacy(&receipt.provider)
14748        }
14749        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14750            !provider_id_is_legacy(&receipt.spawn.provider)
14751        }
14752        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14753        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14754            !provider_id_is_legacy(&route.provider)
14755        }
14756        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14757            !provider_id_is_legacy(&selection.route.provider)
14758        }
14759        _ => false,
14760    };
14761    if contains_open_record {
14762        reply.result = Err(failure(
14763            NodeFailureCode::UnsupportedCapability,
14764            "open provider IDs were not negotiated",
14765        ));
14766        return;
14767    }
14768    let Ok(response) = reply.result.as_mut() else {
14769        return;
14770    };
14771    match response {
14772        NodeResponse::Snapshot { snapshot, .. } => {
14773            project_snapshot_legacy_provider_ids(snapshot);
14774        }
14775        NodeResponse::Resync { snapshot, events, .. } => {
14776            project_snapshot_legacy_provider_ids(snapshot);
14777            *events = std::mem::take(events)
14778                .into_iter()
14779                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14780                .collect();
14781        }
14782        NodeResponse::WorkspaceRegistered { workspace }
14783        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14784        | NodeResponse::WorktreeCreated { workspace, .. } => {
14785            workspace
14786                .sessions
14787                .retain(|session| provider_id_is_legacy(&session.agent_id));
14788        }
14789        NodeResponse::Armed { .. }
14790        | NodeResponse::Spawned { .. }
14791        | NodeResponse::Activated { .. }
14792        | NodeResponse::Aborted { .. }
14793        | NodeResponse::ReplyChunkAccepted { .. }
14794        | NodeResponse::CallRejected { .. }
14795        | NodeResponse::DeliveryStageBegun { .. }
14796        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14797        | NodeResponse::DeliveryCommitted { .. }
14798        | NodeResponse::DeliveryStageAborted { .. }
14799        | NodeResponse::WorkspaceInspected { .. }
14800        | NodeResponse::HostDirectoriesBrowsed { .. }
14801        | NodeResponse::WorkspaceFileRead { .. }
14802        | NodeResponse::WorkspaceFileWritten { .. }
14803        | NodeResponse::WorkspaceFileCreated { .. }
14804        | NodeResponse::WorkspaceDirectoryCreated { .. }
14805        | NodeResponse::GitHistoryRead { .. }
14806        | NodeResponse::GitDiffRead { .. }
14807        | NodeResponse::Controller { .. }
14808        | NodeResponse::SpawnAccepted { .. }
14809        | NodeResponse::SpawnSpecAccepted { .. }
14810        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14811        | NodeResponse::ManagedWorktreeCleanup { .. }
14812        | NodeResponse::NativeSessionsCataloged { .. }
14813        | NodeResponse::NativeSessionsPaged { .. }
14814        | NodeResponse::NativeSessionPreviewed { .. }
14815        | NodeResponse::SessionRecordPreviewed { .. }
14816        | NodeResponse::HistoryDiscovered { .. }
14817        | NodeResponse::HistoryLoaded { .. }
14818        | NodeResponse::ContextPackForSessionRecordExported { .. }
14819        | NodeResponse::ContextPackExported { .. }
14820        | NodeResponse::ContextPackForgotten { .. }
14821        | NodeResponse::DurableContextPackResolved { .. }
14822        | NodeResponse::ContextPackBytesRead { .. }
14823        | NodeResponse::SessionRecordUpdated { .. }
14824        | NodeResponse::ProviderSessionIndexed { .. }
14825        | NodeResponse::NativeSessionIndexed { .. }
14826        | NodeResponse::SessionRecordResumed { .. }
14827        | NodeResponse::SessionRecordForgotten { .. }
14828        | NodeResponse::WorkspaceUnregistered { .. }
14829        | NodeResponse::WorktreeRemoved { .. }
14830        | NodeResponse::Accepted
14831        | NodeResponse::ShuttingDown => {}
14832    }
14833}
14834
14835fn project_event_legacy_provider_ids(
14836    shared: &NodeShared,
14837    mut envelope: NodeEventEnvelope,
14838) -> Option<NodeEventEnvelope> {
14839    let include = match &mut envelope.event {
14840        NodeEvent::HarnessMcpReadCall { .. } => true,
14841        NodeEvent::Control { address, .. }
14842        | NodeEvent::AgentStream { address, .. }
14843        | NodeEvent::TerminalFrame { address, .. } => shared
14844            .validate_address(address)
14845            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14846        NodeEvent::WorkspaceAdded { workspace } => {
14847            workspace
14848                .sessions
14849                .retain(|session| provider_id_is_legacy(&session.agent_id));
14850            true
14851        }
14852        NodeEvent::SessionRecordUpserted { record } => {
14853            provider_id_is_legacy(&record.provider)
14854        }
14855        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14856        NodeEvent::SessionRecordRemoved { .. } => shared
14857            .history
14858            .lock()
14859            .unwrap_or_else(|poisoned| poisoned.into_inner())
14860            .removed_record_providers
14861            .get(&envelope.sequence)
14862            .is_some_and(provider_id_is_legacy),
14863        NodeEvent::ControllerChanged { .. }
14864        | NodeEvent::ManagedWorktreeUpserted { .. }
14865        | NodeEvent::ManagedWorktreeRemoved { .. }
14866        | NodeEvent::WorkspaceRemoved { .. }
14867        | NodeEvent::ResyncRequired { .. } => true,
14868    };
14869    include.then_some(envelope)
14870}
14871
14872fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14873    let Ok(response) = reply.result.as_mut() else {
14874        return;
14875    };
14876    match response {
14877        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14878            snapshot.provider_runtime_statuses.clear();
14879        }
14880        NodeResponse::Armed { .. }
14881        | NodeResponse::Spawned { .. }
14882        | NodeResponse::Activated { .. }
14883        | NodeResponse::Aborted { .. }
14884        | NodeResponse::ReplyChunkAccepted { .. }
14885        | NodeResponse::CallRejected { .. }
14886        | NodeResponse::DeliveryStageBegun { .. }
14887        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14888        | NodeResponse::DeliveryCommitted { .. }
14889        | NodeResponse::DeliveryStageAborted { .. }
14890        | NodeResponse::WorkspaceInspected { .. }
14891        | NodeResponse::HostDirectoriesBrowsed { .. }
14892        | NodeResponse::WorkspaceFileRead { .. }
14893        | NodeResponse::WorkspaceFileWritten { .. }
14894        | NodeResponse::WorkspaceFileCreated { .. }
14895        | NodeResponse::WorkspaceDirectoryCreated { .. }
14896        | NodeResponse::GitHistoryRead { .. }
14897        | NodeResponse::GitDiffRead { .. }
14898        | NodeResponse::Controller { .. }
14899        | NodeResponse::SpawnAccepted { .. }
14900        | NodeResponse::SpawnSpecAccepted { .. }
14901        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14902        | NodeResponse::ManagedWorktreeCleanup { .. }
14903        | NodeResponse::NativeSessionsCataloged { .. }
14904        | NodeResponse::NativeSessionsPaged { .. }
14905        | NodeResponse::NativeSessionPreviewed { .. }
14906        | NodeResponse::SessionRecordPreviewed { .. }
14907        | NodeResponse::HistoryDiscovered { .. }
14908        | NodeResponse::HistoryLoaded { .. }
14909        | NodeResponse::ContextPackForSessionRecordExported { .. }
14910        | NodeResponse::ContextPackExported { .. }
14911        | NodeResponse::ContextPackForgotten { .. }
14912        | NodeResponse::DurableContextPackResolved { .. }
14913        | NodeResponse::ContextPackBytesRead { .. }
14914        | NodeResponse::SessionRecordUpdated { .. }
14915        | NodeResponse::ProviderSessionIndexed { .. }
14916        | NodeResponse::NativeSessionIndexed { .. }
14917        | NodeResponse::SessionRecordResumed { .. }
14918        | NodeResponse::SessionRecordForgotten { .. }
14919        | NodeResponse::WorkspaceRegistered { .. }
14920        | NodeResponse::StandaloneWorkspaceCreated { .. }
14921        | NodeResponse::WorkspaceUnregistered { .. }
14922        | NodeResponse::WorktreeCreated { .. }
14923        | NodeResponse::WorktreeRemoved { .. }
14924        | NodeResponse::Accepted
14925        | NodeResponse::ShuttingDown => {}
14926    }
14927}
14928
14929struct ControllerReleaseGuard {
14930    shared: Arc<NodeShared>,
14931    connection_id: u64,
14932}
14933
14934impl Drop for ControllerReleaseGuard {
14935    fn drop(&mut self) {
14936        self.shared.release_controller(self.connection_id);
14937    }
14938}
14939
14940struct AbortTaskOnDrop(AbortHandle);
14941
14942impl Drop for AbortTaskOnDrop {
14943    fn drop(&mut self) {
14944        self.0.abort();
14945    }
14946}
14947
14948/// Reads an unsigned env-config override, following the pattern of every
14949/// other bounded knob in this module: missing/unparsable/zero falls back to
14950/// `default`, and the resolved value is always clamped to `max` so a bad
14951/// override cannot reopen a bound this budget exists to enforce.
14952fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14953    std::env::var(name)
14954        .ok()
14955        .and_then(|value| value.parse::<u64>().ok())
14956        .filter(|value| *value > 0)
14957        .unwrap_or(default)
14958        .min(max)
14959}
14960
14961fn workspace_inspection_time_budget_ms() -> u64 {
14962    env_config_bounded(
14963        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14964        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14965        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14966    )
14967}
14968
14969fn workspace_inspection_entry_cap() -> usize {
14970    env_config_bounded(
14971        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14972        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14973        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14974    ) as usize
14975}
14976
14977/// Tracks the shared walk-side budget across the whole recursive walk: a
14978/// wall-clock deadline (shared with the git phase that runs after the walk)
14979/// and a cap on directory entries visited, whether or not they were pushed
14980/// to the response. Cheap to check per entry — one `Instant::now()` and one
14981/// integer compare.
14982struct WorkspaceWalkBudget {
14983    deadline: Instant,
14984    entry_cap: usize,
14985    entries_visited: u64,
14986    time_budget_exceeded: bool,
14987    entry_cap_exceeded: bool,
14988}
14989
14990impl WorkspaceWalkBudget {
14991    fn new(deadline: Instant, entry_cap: usize) -> Self {
14992        Self {
14993            deadline,
14994            entry_cap,
14995            entries_visited: 0,
14996            time_budget_exceeded: false,
14997            entry_cap_exceeded: false,
14998        }
14999    }
15000
15001    fn is_exhausted(&self) -> bool {
15002        self.time_budget_exceeded || self.entry_cap_exceeded
15003    }
15004
15005    /// Records one visited directory entry (pushed to the response or not)
15006    /// and reports whether the walk must stop now.
15007    fn record_visit(&mut self) -> bool {
15008        self.entries_visited += 1;
15009        if self.entries_visited as usize >= self.entry_cap {
15010            self.entry_cap_exceeded = true;
15011        }
15012        if Instant::now() >= self.deadline {
15013            self.time_budget_exceeded = true;
15014        }
15015        self.is_exhausted()
15016    }
15017}
15018
15019fn collect_workspace_entries(
15020    root: &Path,
15021    deadline: Instant,
15022    entry_cap: usize,
15023) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15024    let mut entries = Vec::new();
15025    let mut truncated = false;
15026    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15027    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15028    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15029    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15030    // require strict global byte order over relative paths.
15031    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15032    (entries, truncated, budget)
15033}
15034
15035/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15036/// `inspect_workspace` response — `None` once the walk and git phase both
15037/// completed inside their shared budget.
15038fn workspace_inspection_truncation(
15039    walk_budget: &WorkspaceWalkBudget,
15040    git_time_budget_exceeded: bool,
15041    elapsed: Duration,
15042) -> Option<WorkspaceInspectionTruncationV1> {
15043    if !walk_budget.time_budget_exceeded
15044        && !walk_budget.entry_cap_exceeded
15045        && !git_time_budget_exceeded
15046    {
15047        return None;
15048    }
15049    Some(WorkspaceInspectionTruncationV1 {
15050        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15051        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15052        git_time_budget_exceeded,
15053        entries_visited: walk_budget.entries_visited,
15054        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15055    })
15056}
15057
15058fn walk_workspace_directory(
15059    directory: &Path,
15060    relative_directory: Option<&RepositoryPath>,
15061    depth: usize,
15062    entries: &mut Vec<WorkspaceEntry>,
15063    truncated: &mut bool,
15064    budget: &mut WorkspaceWalkBudget,
15065) {
15066    if budget.is_exhausted() {
15067        *truncated = true;
15068        return;
15069    }
15070    let read_dir = match std::fs::read_dir(directory) {
15071        Ok(read_dir) => read_dir,
15072        Err(_) => {
15073            *truncated = true;
15074            return;
15075        }
15076    };
15077    let mut children = Vec::new();
15078    for child in read_dir {
15079        match child {
15080            Ok(child) => children.push(child),
15081            Err(_) => *truncated = true,
15082        }
15083    }
15084    children.sort_by_key(|child| child.file_name());
15085    for child in children {
15086        if budget.record_visit() {
15087            *truncated = true;
15088            return;
15089        }
15090        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15091            *truncated = true;
15092            return;
15093        }
15094        let file_type = match child.file_type() {
15095            Ok(file_type) => file_type,
15096            Err(_) => {
15097                *truncated = true;
15098                continue;
15099            }
15100        };
15101        if file_type.is_symlink() {
15102            continue;
15103        }
15104        let name = match child.file_name().into_string() {
15105            Ok(name) => name,
15106            Err(_) => {
15107                *truncated = true;
15108                continue;
15109            }
15110        };
15111        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15112            continue;
15113        }
15114        let relative_path = match relative_directory {
15115            None => windows_repository_path(name),
15116            Some(parent) => parent
15117                .as_utf8()
15118                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15119        };
15120        let Some(relative_path) = relative_path else {
15121            *truncated = true;
15122            continue;
15123        };
15124        let kind = if file_type.is_dir() {
15125            WorkspaceEntryKind::Directory
15126        } else if file_type.is_file() {
15127            WorkspaceEntryKind::File
15128        } else {
15129            continue;
15130        };
15131        entries.push(WorkspaceEntry {
15132            relative_path: relative_path.clone(),
15133            kind,
15134        });
15135        if file_type.is_dir() {
15136            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15137                walk_workspace_directory(
15138                    &child.path(),
15139                    Some(&relative_path),
15140                    depth + 1,
15141                    entries,
15142                    truncated,
15143                    budget,
15144                );
15145            } else {
15146                *truncated = true;
15147            }
15148        }
15149    }
15150}
15151
15152fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15153    if value.contains('\\') {
15154        return None;
15155    }
15156    RepositoryPath::utf8(value).ok()
15157}
15158
15159fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15160    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15161}
15162
15163/// Excludes a directory from the workspace tree walk. The three literal
15164/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15165/// beneath them catches every build-output directory regardless of name
15166/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15167/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15168/// directory without maintaining a name heuristic list). The name check
15169/// runs first and returns early so a directory rejected by name never pays
15170/// for the extra `CACHEDIR.TAG` stat.
15171fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15172    if name.eq_ignore_ascii_case(".git")
15173        || name.eq_ignore_ascii_case("target")
15174        || name.eq_ignore_ascii_case("node_modules")
15175    {
15176        return true;
15177    }
15178    path.join("CACHEDIR.TAG").is_file()
15179}
15180
15181fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15182    matches!(
15183        status,
15184        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15185    )
15186}
15187
15188#[derive(Clone, Debug, Eq, PartialEq)]
15189enum ContextPackRepositoryHead {
15190    NotRepository,
15191    Unborn,
15192    Commit(String),
15193}
15194
15195#[derive(Clone, Debug, Eq, PartialEq)]
15196struct ContextPackRepositoryObservation {
15197    head: ContextPackRepositoryHead,
15198    git: GitSnapshot,
15199    files: Vec<ContextPackRepositoryFileSource>,
15200}
15201
15202fn stable_context_pack_repository(
15203    first: ContextPackRepositoryObservation,
15204    second: ContextPackRepositoryObservation,
15205) -> Result<ContextPackRepository, NodeFailure> {
15206    if first != second {
15207        return Err(failure(
15208            NodeFailureCode::ContextPackMaterializationFailed,
15209            "context repository changed during bounded capture",
15210        ));
15211    }
15212    Ok(ContextPackRepository::from_sources(
15213        &second.git,
15214        second.files,
15215    ))
15216}
15217
15218/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15219/// (and only once) the shared walk+git `deadline` has already passed. Every
15220/// remaining phase in `inspect_git_workspace` checks this immediately
15221/// before issuing its own git call, so a budget spent by the walk (or by an
15222/// earlier git call) skips the rest of the chain instead of paying for it
15223/// anyway.
15224fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15225    if Instant::now() < deadline {
15226        return false;
15227    }
15228    append_git_diagnostic(
15229        snapshot,
15230        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15231    );
15232    true
15233}
15234
15235/// Deadline-aware sibling of `run_git_bounded`, used only by
15236/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15237/// whatever remains of the shared `deadline` (never more than
15238/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15239/// of the budget cannot still hand a full per-call timeout to every git
15240/// probe behind it. `inspect_git_workspace` already guards each call site
15241/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15242/// passed yet here.
15243async fn run_git_bounded_with_deadline(
15244    root: &str,
15245    arguments: &[&str],
15246    output_limit: usize,
15247    deadline: Instant,
15248) -> io::Result<GitCommandOutput> {
15249    let remaining = deadline.checked_duration_since(Instant::now());
15250    let timeout_ms = remaining
15251        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15252        .unwrap_or(0)
15253        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15254    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15255}
15256
15257/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15258/// `deadline` is the same absolute instant the preceding directory walk was
15259/// bounded by, so time already spent walking counts against what remains
15260/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15261/// shared budget (not an individual per-call timeout) is what cut the git
15262/// phase short.
15263async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15264    let mut snapshot = GitSnapshot {
15265        is_repository: false,
15266        branch: None,
15267        status: Vec::new(),
15268        recent_commits: Vec::new(),
15269        worktrees: Vec::new(),
15270        managed_worktree: None,
15271        truncated: false,
15272        diagnostic: None,
15273    };
15274    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15275        return (snapshot, true);
15276    }
15277    let repository = match run_git_bounded_with_deadline(
15278        root,
15279        &["rev-parse", "--is-inside-work-tree"],
15280        4 * 1_024,
15281        deadline,
15282    )
15283    .await
15284    {
15285        Ok(output) => output,
15286        Err(error) => {
15287            append_git_diagnostic(
15288                &mut snapshot,
15289                &format!("git inspection unavailable: {error}"),
15290            );
15291            return (snapshot, false);
15292        }
15293    };
15294    snapshot.truncated |= repository.truncated;
15295    if repository.timed_out {
15296        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15297        return (snapshot, false);
15298    }
15299    if !repository.success
15300        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15301    {
15302        let stderr = String::from_utf8_lossy(&repository.stderr);
15303        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15304            append_git_diagnostic(&mut snapshot, stderr.trim());
15305        }
15306        return (snapshot, false);
15307    }
15308    snapshot.is_repository = true;
15309
15310    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15311        return (snapshot, true);
15312    }
15313    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15314        Ok(output) => {
15315            snapshot.truncated |= output.truncated;
15316            if output.timed_out {
15317                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15318            } else if output.success {
15319                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15320                if !branch.is_empty() {
15321                    snapshot.branch = Some(truncate_git_field(
15322                        &branch,
15323                        MAX_REPOSITORY_PATH_BYTES,
15324                        &mut snapshot.truncated,
15325                    ));
15326                }
15327            }
15328        }
15329        Err(error) => append_git_diagnostic(
15330            &mut snapshot,
15331            &format!("git branch query failed: {error}"),
15332        ),
15333    }
15334    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15335        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15336            snapshot.truncated |= output.truncated;
15337            if output.success && !output.timed_out {
15338                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15339                if !head.is_empty() {
15340                    snapshot.branch = Some(format!("detached:{head}"));
15341                }
15342            }
15343        }
15344    }
15345
15346    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15347        return (snapshot, true);
15348    }
15349    match run_git_bounded_with_deadline(
15350        root,
15351        &[
15352            "status",
15353            "--porcelain=v1",
15354            "-z",
15355            "--renames",
15356            "--untracked-files=normal",
15357            "--",
15358            ".",
15359        ],
15360        GIT_OUTPUT_MAX_BYTES,
15361        deadline,
15362    )
15363    .await
15364    {
15365        Ok(output) => {
15366            snapshot.truncated |= output.truncated;
15367            if output.timed_out {
15368                append_git_diagnostic(&mut snapshot, "git status timed out");
15369            } else if output.success {
15370                parse_git_status(&output.stdout, &mut snapshot);
15371            } else {
15372                append_git_diagnostic(
15373                    &mut snapshot,
15374                    String::from_utf8_lossy(&output.stderr).trim(),
15375                );
15376            }
15377        }
15378        Err(error) => append_git_diagnostic(
15379            &mut snapshot,
15380            &format!("git status failed: {error}"),
15381        ),
15382    }
15383
15384    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15385        return (snapshot, true);
15386    }
15387    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15388    match run_git_bounded_with_deadline(
15389        root,
15390        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15391        16 * 1_024,
15392        deadline,
15393    )
15394    .await
15395    {
15396        Ok(output) => {
15397            snapshot.truncated |= output.truncated;
15398            if output.timed_out {
15399                append_git_diagnostic(&mut snapshot, "git log timed out");
15400            } else if output.success {
15401                parse_git_commits(&output.stdout, &mut snapshot);
15402            } else {
15403                let stderr = String::from_utf8_lossy(&output.stderr);
15404                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15405                    append_git_diagnostic(&mut snapshot, stderr.trim());
15406                }
15407            }
15408        }
15409        Err(error) => append_git_diagnostic(
15410            &mut snapshot,
15411            &format!("git log failed: {error}"),
15412        ),
15413    }
15414
15415    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15416        return (snapshot, true);
15417    }
15418    match list_git_worktrees_with_deadline(root, deadline).await {
15419        Ok(worktrees) => {
15420            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15421        }
15422        Err(error) => append_git_diagnostic(
15423            &mut snapshot,
15424            &format!("git worktree list failed: {}", error.message),
15425        ),
15426    }
15427    (snapshot, false)
15428}
15429
15430fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15431    let mut remaining = output;
15432    while !remaining.is_empty() {
15433        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15434            snapshot.truncated = true;
15435            break;
15436        }
15437        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15438            snapshot.truncated = true;
15439            break;
15440        };
15441        let record = &remaining[..record_end];
15442        remaining = &remaining[record_end + 1..];
15443        let has_previous_path = record
15444            .get(..2)
15445            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15446        let previous_path_bytes = if has_previous_path {
15447            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15448                snapshot.truncated = true;
15449                break;
15450            };
15451            let previous = &remaining[..previous_end];
15452            remaining = &remaining[previous_end + 1..];
15453            Some(previous)
15454        } else {
15455            None
15456        };
15457        if record.len() < 4
15458            || record[2] != b' '
15459            || !is_git_status_code(record[0])
15460            || !is_git_status_code(record[1])
15461        {
15462            snapshot.truncated = true;
15463            continue;
15464        }
15465        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15466            snapshot.truncated = true;
15467            continue;
15468        };
15469        let previous_path = match previous_path_bytes {
15470            Some(previous) => {
15471                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15472                    snapshot.truncated = true;
15473                    continue;
15474                };
15475                Some(previous)
15476            }
15477            None => None,
15478        };
15479        snapshot.status.push(GitStatusEntry {
15480            index_status: (record[0] as char).to_string(),
15481            worktree_status: (record[1] as char).to_string(),
15482            path,
15483            previous_path,
15484        });
15485    }
15486}
15487
15488fn is_git_status_code(byte: u8) -> bool {
15489    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15490}
15491
15492fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15493    for line in String::from_utf8_lossy(output).lines() {
15494        let Some((id, summary)) = line.split_once('\u{1f}') else {
15495            continue;
15496        };
15497        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15498            snapshot.truncated = true;
15499            break;
15500        }
15501        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15502        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15503        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15504    }
15505}
15506
15507fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15508    if value.len() <= max_bytes {
15509        return value.to_owned();
15510    }
15511    *truncated = true;
15512    let mut boundary = max_bytes;
15513    while boundary > 0 && !value.is_char_boundary(boundary) {
15514        boundary -= 1;
15515    }
15516    value[..boundary].to_owned()
15517}
15518
15519fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15520    if message.is_empty() {
15521        return;
15522    }
15523    let normalized = message
15524        .chars()
15525        .map(|character| if character.is_control() { ' ' } else { character })
15526        .collect::<String>();
15527    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15528    if !diagnostic.is_empty() {
15529        diagnostic.push_str("; ");
15530    }
15531    diagnostic.push_str(normalized.trim());
15532    snapshot.diagnostic = Some(truncate_git_field(
15533        &diagnostic,
15534        GIT_DIAGNOSTIC_MAX_BYTES,
15535        &mut snapshot.truncated,
15536    ));
15537}
15538
15539async fn run_git_bounded(
15540    root: &str,
15541    arguments: &[&str],
15542    output_limit: usize,
15543) -> io::Result<GitCommandOutput> {
15544    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15545}
15546
15547async fn read_git_history_bounded(
15548    root: &str,
15549    path: Option<&RepositoryPath>,
15550    before: Option<&GitObjectId>,
15551    limit: u16,
15552) -> Result<GitHistoryPage, NodeFailure> {
15553    let path = path
15554        .map(|path| {
15555            path.as_utf8().ok_or_else(|| {
15556                failure(
15557                    NodeFailureCode::InvalidRepositoryPath,
15558                    "git history path must use UTF-8",
15559                )
15560            })
15561        })
15562        .transpose()?
15563        .unwrap_or(".");
15564    let count = (usize::from(limit) + 1).to_string();
15565    let start = before
15566        .map(|revision| format!("{}^", revision.as_str()))
15567        .unwrap_or_else(|| "HEAD".to_owned());
15568    let arguments = [
15569        "--no-pager",
15570        "log",
15571        "--no-decorate",
15572        "--date=iso-strict",
15573        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15574        "-n",
15575        count.as_str(),
15576        start.as_str(),
15577        "--",
15578        path,
15579    ];
15580    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15581        .await
15582        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15583    if output.timed_out {
15584        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15585    }
15586    if !output.success {
15587        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15588    }
15589    let mut commits = parse_git_history(&output.stdout);
15590    let has_more = commits.len() > usize::from(limit) || output.truncated;
15591    commits.truncate(usize::from(limit));
15592    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15593    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15594}
15595
15596fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15597    String::from_utf8_lossy(output)
15598        .split('\u{1e}')
15599        .filter_map(|record| {
15600            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15601            if fields.len() != 9 {
15602                return None;
15603            }
15604            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15605            let parents = if fields[1].is_empty() {
15606                Vec::new()
15607            } else {
15608                fields[1]
15609                    .split(' ')
15610                    .map(|value| GitObjectId::new(value.to_owned()))
15611                    .collect::<Result<Vec<_>, _>>()
15612                    .ok()?
15613            };
15614            Some(GitCommitDetails {
15615                id,
15616                parents,
15617                subject: bounded_git_text(fields[2], 512),
15618                author_name: bounded_git_text(fields[3], 256),
15619                author_email: bounded_git_text(fields[4], 320),
15620                authored_at: bounded_git_text(fields[5], 64),
15621                committer_name: bounded_git_text(fields[6], 256),
15622                committer_email: bounded_git_text(fields[7], 320),
15623                committed_at: bounded_git_text(fields[8], 64),
15624                signature_status: GitSignatureStatus::NoSignature,
15625                signer: None,
15626            })
15627        })
15628        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15629        .collect()
15630}
15631
15632fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15633    let normalized = value
15634        .chars()
15635        .map(|character| if character.is_control() { ' ' } else { character })
15636        .collect::<String>();
15637    if normalized.len() <= max_bytes {
15638        return normalized;
15639    }
15640    let mut boundary = max_bytes;
15641    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15642        boundary -= 1;
15643    }
15644    normalized[..boundary].to_owned()
15645}
15646
15647async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15648    let path = request.path.as_ref().map(|path| {
15649        path.as_utf8()
15650            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15651    }).transpose()?;
15652    let mut arguments = match &request.mode {
15653        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15654        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15655        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15656    };
15657    if let GitDiffMode::Commit { revision } = &request.mode {
15658        arguments.push(revision.as_str());
15659    }
15660    arguments.push("--");
15661    if let Some(path) = path {
15662        arguments.push(path);
15663    }
15664    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15665        .await
15666        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15667    if output.timed_out {
15668        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15669    }
15670    if !output.success {
15671        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15672    }
15673    Ok(GitDiff {
15674        mode: request.mode,
15675        path: request.path,
15676        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15677        truncated: output.truncated,
15678    })
15679}
15680
15681async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15682    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15683    ResponseEnvelope { request_id: envelope.request_id, result }
15684}
15685
15686async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15687    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15688        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15689    }
15690    let read_only = matches!(
15691        &request,
15692        NodeRequest::Snapshot
15693            | NodeRequest::Resync { .. }
15694            | NodeRequest::BrowseHostDirectories { .. }
15695            | NodeRequest::InspectWorkspace { .. }
15696            | NodeRequest::ReadWorkspaceFile { .. }
15697            | NodeRequest::ReadGitHistory { .. }
15698            | NodeRequest::ReadGitDiff { .. }
15699            | NodeRequest::CatalogNativeSessions { .. }
15700            | NodeRequest::PageNativeSessions { .. }
15701            | NodeRequest::PreviewNativeSession { .. }
15702            | NodeRequest::PreviewSessionRecord { .. }
15703    );
15704    let _mutation_guard = if read_only {
15705        None
15706    } else {
15707        let guard = shared.mutation_gate.lock().await;
15708        if shared.shutdown.load(Ordering::Acquire) {
15709            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15710        }
15711        Some(guard)
15712    };
15713    match request {
15714        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15715            event_sequence: shared.current_sequence(),
15716            controller: shared.controller_state(),
15717            snapshot: shared.snapshot(),
15718        }),
15719        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15720        NodeRequest::ArmHarnessMcpReservation {
15721            reservation_id,
15722            activation_digest,
15723            spawn_spec,
15724            launch,
15725            expires_at_unix_ms,
15726        } => {
15727            shared.require_controller(connection_id, role)?;
15728            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15729                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15730            })?;
15731            let expires_at_unix_ms = registry
15732                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15733                .await
15734                .map_err(harness_mcp_failure)?;
15735            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15736        }
15737        NodeRequest::SpawnSpecWithHarnessMcp {
15738            reservation_id,
15739            activation_digest,
15740            spec,
15741            deadline_unix_ms,
15742        } => {
15743            shared.require_controller(connection_id, role)?;
15744            let receipt = shared
15745                .spawn_from_spec_with_harness_mcp(
15746                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15747                )
15748                .await?;
15749            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15750        }
15751        NodeRequest::ActivateHarnessMcpReservation {
15752            reservation_id,
15753            activation_digest,
15754            record_id,
15755            session,
15756        } => {
15757            shared.require_controller(connection_id, role)?;
15758            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15759                current.instance_id == session.session.instance_id
15760                    && current.generation == session.session.generation
15761            }).and_then(|current| current.process_id).ok_or_else(|| {
15762                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15763            })?;
15764            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15765                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15766            })?.activate(
15767                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15768            ).map_err(harness_mcp_failure)?;
15769            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15770        }
15771        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15772            shared.require_controller(connection_id, role)?;
15773            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15774                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15775            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15776            if let Some(instance_id) = instance_id {
15777                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15778            }
15779            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15780        }
15781        NodeRequest::PutHarnessMcpReplyChunk {
15782            reservation_id, activation_digest, record_id, session, call_id,
15783            offset, final_chunk, chunk_hex,
15784        } => {
15785            shared.require_controller(connection_id, role)?;
15786            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15787                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15788            })?.put_reply_chunk(
15789                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15790                offset, final_chunk, &chunk_hex,
15791            ).map_err(harness_mcp_failure)?;
15792            Ok(NodeResponse::ReplyChunkAccepted {
15793                reservation_id, activation_digest, record_id, session, call_id,
15794                next_offset, completed,
15795            })
15796        }
15797        NodeRequest::RejectHarnessMcpCall {
15798            reservation_id, activation_digest, record_id, session, call_id, reason,
15799        } => {
15800            shared.require_controller(connection_id, role)?;
15801            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15802                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15803            })?.reject_call(
15804                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15805            ).map_err(harness_mcp_failure)?;
15806            Ok(NodeResponse::CallRejected {
15807                reservation_id, activation_digest, record_id, session, call_id,
15808            })
15809        }
15810        NodeRequest::BeginDeliveryStage { manifest } => {
15811            shared.require_controller(connection_id, role)?;
15812            shared.begin_delivery_stage(manifest)
15813        }
15814        NodeRequest::PutDeliveryBlobChunk {
15815            stage_id,
15816            blob_digest,
15817            offset,
15818            chunk_hex,
15819        } => {
15820            shared.require_controller(connection_id, role)?;
15821            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15822        }
15823        NodeRequest::CommitDeliveryStage { stage_id } => {
15824            shared.require_controller(connection_id, role)?;
15825            let receipt = shared.commit_delivery_stage(stage_id)?;
15826            Ok(NodeResponse::DeliveryCommitted { receipt })
15827        }
15828        NodeRequest::AbortDeliveryStage { stage_id } => {
15829            shared.require_controller(connection_id, role)?;
15830            shared.abort_delivery_stage(&stage_id)?;
15831            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15832        }
15833        NodeRequest::BrowseHostDirectories { directory, after } => {
15834            let listing = shared.browse_host_directories(directory, after).await?;
15835            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15836        }
15837        NodeRequest::InspectWorkspace { workspace_id } => {
15838            let inspection = shared.inspect_workspace(workspace_id).await?;
15839            Ok(NodeResponse::WorkspaceInspected { inspection })
15840        }
15841        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15842            let file = shared.read_workspace_file(workspace_id, path).await?;
15843            Ok(NodeResponse::WorkspaceFileRead { file })
15844        }
15845        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15846            shared.require_controller(connection_id, role)?;
15847            let file = shared
15848                .write_workspace_file(workspace_id, path, expected_revision, text)
15849                .await?;
15850            Ok(NodeResponse::WorkspaceFileWritten { file })
15851        }
15852        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15853            shared.require_controller(connection_id, role)?;
15854            let file = shared.create_workspace_file(workspace_id, path).await?;
15855            Ok(NodeResponse::WorkspaceFileCreated { file })
15856        }
15857        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15858            shared.require_controller(connection_id, role)?;
15859            let entry = shared
15860                .create_workspace_directory(workspace_id.clone(), path)
15861                .await?;
15862            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15863        }
15864        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15865            if role != ClientRole::Operator {
15866                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15867            }
15868            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15869                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15870            }
15871            let page = shared
15872                .read_git_history(workspace_id.clone(), path, before, limit)
15873                .await?;
15874            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15875        }
15876        NodeRequest::ReadGitDiff { workspace_id, request } => {
15877            if role != ClientRole::Operator {
15878                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15879            }
15880            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15881            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15882        }
15883        NodeRequest::CatalogNativeSessions { route, limit } => {
15884            if role != ClientRole::Operator {
15885                return Err(failure(
15886                    NodeFailureCode::ObserverReadOnly,
15887                    "native session catalog requires an operator connection",
15888                ));
15889            }
15890            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15891                return Err(failure(
15892                    NodeFailureCode::InvalidRequest,
15893                    "native session catalog limit is invalid",
15894                ));
15895            }
15896            let (entries, summary) = shared
15897                .catalog_native_sessions(route.clone(), limit)
15898                .await?;
15899            Ok(NodeResponse::NativeSessionsCataloged {
15900                route,
15901                entries,
15902                summary: Some(summary),
15903            })
15904        }
15905        NodeRequest::PageNativeSessions {
15906            route,
15907            window,
15908            catalog_revision,
15909            recent_cutoff_unix_ms,
15910            after_selection_id,
15911            limit,
15912        } => {
15913            if role != ClientRole::Operator {
15914                return Err(failure(
15915                    NodeFailureCode::ObserverReadOnly,
15916                    "native session catalog paging requires an operator connection",
15917                ));
15918            }
15919            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15920                || after_selection_id
15921                    .as_deref()
15922                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15923            {
15924                return Err(failure(
15925                    NodeFailureCode::InvalidRequest,
15926                    "native session catalog page request is invalid",
15927                ));
15928            }
15929            let page = shared
15930                .page_native_sessions(
15931                    route.clone(),
15932                    window,
15933                    catalog_revision,
15934                    recent_cutoff_unix_ms,
15935                    after_selection_id,
15936                    limit,
15937                )
15938                .await?;
15939            Ok(NodeResponse::NativeSessionsPaged {
15940                route,
15941                page,
15942            })
15943        }
15944        NodeRequest::PreviewNativeSession {
15945            selection,
15946            message_limit,
15947        } => {
15948            if role != ClientRole::Operator {
15949                return Err(failure(
15950                    NodeFailureCode::ObserverReadOnly,
15951                    "native session preview requires an operator connection",
15952                ));
15953            }
15954            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15955                .contains(&message_limit)
15956                || selection.validate().is_err()
15957            {
15958                return Err(failure(
15959                    NodeFailureCode::InvalidRequest,
15960                    "native session preview request is invalid",
15961                ));
15962            }
15963            let preview = shared
15964                .preview_native_session(selection.clone(), message_limit)
15965                .await?;
15966            Ok(NodeResponse::NativeSessionPreviewed {
15967                selection,
15968                preview,
15969            })
15970        }
15971        NodeRequest::IndexNativeSession {
15972            selection,
15973            display_name,
15974        } => {
15975            shared.require_controller(connection_id, role)?;
15976            let record = shared
15977                .index_native_session(selection.clone(), display_name)
15978                .await?;
15979            Ok(NodeResponse::NativeSessionIndexed { selection, record })
15980        }
15981        NodeRequest::PreviewSessionRecord {
15982            record_id,
15983            message_limit,
15984        } => {
15985            if role != ClientRole::Operator {
15986                return Err(failure(
15987                    NodeFailureCode::ObserverReadOnly,
15988                    "native session preview requires an operator connection",
15989                ));
15990            }
15991            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15992                .contains(&message_limit)
15993            {
15994                return Err(failure(
15995                    NodeFailureCode::InvalidRequest,
15996                    "native session preview request is invalid",
15997                ));
15998            }
15999            let record = shared.record(&record_id)?;
16000            let working_directory = shared.workspace_root(&record.workspace_id)?;
16001            if !platform::roots_equal(
16002                &working_directory,
16003                windows_path_text(&record.canonical_root),
16004            ) {
16005                return Err(failure(
16006                    NodeFailureCode::SessionRecordConflict,
16007                    "managed session workspace root changed; refusing to preview another directory",
16008                ));
16009            }
16010            let identity = record.provider_session.clone().ok_or_else(|| {
16011                failure(
16012                    NodeFailureCode::BackendOperationFailed,
16013                    "native session preview is unavailable for this session record",
16014                )
16015            })?;
16016            if identity.key != ProviderSessionKey::SessionId {
16017                return Err(failure(
16018                    NodeFailureCode::BackendOperationFailed,
16019                    "native session preview is unavailable for this session identity",
16020                ));
16021            }
16022            let preview = shared
16023                .preview_native_session_by_session_id(
16024                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16025                )
16026                .await?;
16027            if preview.session_id != identity.id {
16028                return Err(failure(
16029                    NodeFailureCode::SessionRecordConflict,
16030                    "native session preview identity changed while loading",
16031                ));
16032            }
16033            shared.revalidate_session_record_preview(&record, &identity)?;
16034            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16035            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16036                record_id: record_id.clone(),
16037                summary: SessionHistorySummaryV1::from(&preview),
16038            });
16039            Ok(NodeResponse::SessionRecordPreviewed {
16040                record_id,
16041                preview,
16042            })
16043        }
16044        NodeRequest::AcquireController { lease_ms } => {
16045            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16046            Ok(NodeResponse::Controller { controller: Some(controller) })
16047        }
16048        NodeRequest::ReleaseController => {
16049            shared.release_controller(connection_id);
16050            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16051        }
16052        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16053            shared.require_controller(connection_id, role)?;
16054            let root = require_windows_path(root)?;
16055            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16056            let workspace = shared.register_workspace(workspace_id, root).await?;
16057            Ok(NodeResponse::WorkspaceRegistered { workspace })
16058        }
16059        NodeRequest::CreateStandaloneWorkspace {
16060            workspace_id,
16061            root,
16062            initial_branch,
16063        } => {
16064            shared.require_controller(connection_id, role)?;
16065            let root = require_windows_path(root)?;
16066            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16067            let workspace = shared
16068                .create_standalone_workspace(workspace_id, root, initial_branch)
16069                .await?;
16070            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16071        }
16072        NodeRequest::UnregisterWorkspace { workspace_id } => {
16073            shared.require_controller(connection_id, role)?;
16074            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16075            shared.unregister_workspace(&workspace_id)?;
16076            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16077        }
16078        NodeRequest::CreateWorktree {
16079            source_workspace_id,
16080            workspace_id,
16081            target_root,
16082            branch,
16083            base,
16084        } => {
16085            shared.require_controller(connection_id, role)?;
16086            let target_root = require_windows_path(target_root)?;
16087            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16088            let (worktree, workspace) = shared
16089                .create_worktree(
16090                    source_workspace_id,
16091                    workspace_id,
16092                    target_root,
16093                    branch,
16094                    base,
16095                )
16096                .await?;
16097            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16098        }
16099        NodeRequest::RemoveWorktree {
16100            source_workspace_id,
16101            target_root,
16102        } => {
16103            shared.require_controller(connection_id, role)?;
16104            let native_target_root = require_windows_path(target_root.clone())?;
16105            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16106            let workspace_id = shared
16107                .remove_worktree(source_workspace_id, native_target_root)
16108                .await?;
16109            Ok(NodeResponse::WorktreeRemoved {
16110                target_root,
16111                workspace_id,
16112            })
16113        }
16114        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16115            shared.require_controller(connection_id, role)?;
16116            if !terminal_size.is_valid() {
16117                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16118            }
16119            if let Some(prompt) = initial_prompt.as_deref() {
16120                validate_node_text("spawn initial prompt", prompt)?;
16121            }
16122            match shared
16123                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16124                .await
16125            {
16126                Ok(session) => Ok(NodeResponse::SpawnAccepted { session }),
16127                Err(err) => {
16128                    tracing::warn!(
16129                        %provider,
16130                        %workspace_id,
16131                        ?mode,
16132                        code = ?err.code,
16133                        message = %err.message,
16134                        "session spawn refused"
16135                    );
16136                    Err(err)
16137                }
16138            }
16139        }
16140        NodeRequest::SpawnSpec { spec } => {
16141            shared.require_controller(connection_id, role)?;
16142            let receipt = shared.spawn_from_spec(spec).await?;
16143            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16144        }
16145        NodeRequest::SpawnManagedWorktree { request } => {
16146            shared.require_controller(connection_id, role)?;
16147            let receipt = shared.spawn_managed_worktree(request).await?;
16148            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16149        }
16150        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16151            shared.require_controller(connection_id, role)?;
16152            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16153            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16154        }
16155        NodeRequest::CleanupManagedWorktree { lease_id } => {
16156            shared.require_controller(connection_id, role)?;
16157            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16158            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16159        }
16160        NodeRequest::DiscoverHistory { session, limit } => {
16161            controlled_session(shared, connection_id, role, &session)?;
16162            let candidates = shared.discover_history(&session, limit).await?;
16163            Ok(NodeResponse::HistoryDiscovered {
16164                session,
16165                candidates,
16166            })
16167        }
16168        NodeRequest::LoadHistory {
16169            session,
16170            candidate_id,
16171        } => {
16172            controlled_session(shared, connection_id, role, &session)?;
16173            let loaded = shared
16174                .load_history(&session, candidate_id)
16175                .await?;
16176            Ok(NodeResponse::HistoryLoaded {
16177                session,
16178                session_id: loaded.session_id,
16179                message_count: loaded.message_count,
16180                completed_turn_count: loaded.completed_turn_count,
16181            })
16182        }
16183        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16184            shared.require_controller(connection_id, role)?;
16185            let context = shared
16186                .export_context_pack_for_session_record(&record_id, &session, false)
16187                .await?;
16188            Ok(NodeResponse::ContextPackForSessionRecordExported {
16189                record_id,
16190                session,
16191                context,
16192            })
16193        }
16194        NodeRequest::ExportContextPack { session } => {
16195            controlled_session(shared, connection_id, role, &session)?;
16196            let context = shared.export_context_pack(&session).await?;
16197            Ok(NodeResponse::ContextPackExported { context })
16198        }
16199        NodeRequest::ForgetContextPack { context_id } => {
16200            shared.require_controller(connection_id, role)?;
16201            shared.forget_context_pack(&context_id)?;
16202            Ok(NodeResponse::ContextPackForgotten { context_id })
16203        }
16204        NodeRequest::ResolveDurableContextPack { context_id } => {
16205            // Read-only, idempotent, no controller lease required: this lets
16206            // a caller preflight "is this durably exported pack still here"
16207            // without holding the Node's mutation controller.
16208            let context = shared
16209                .context_catalog
16210                .read()
16211                .unwrap_or_else(|poisoned| poisoned.into_inner())
16212                .get(&context_id)
16213                .map(|pack| pack.receipt().clone())
16214                .ok_or_else(|| {
16215                    failure(
16216                        NodeFailureCode::UnknownContextPack,
16217                        "context pack is not present",
16218                    )
16219                })?;
16220            Ok(NodeResponse::DurableContextPackResolved { context })
16221        }
16222        NodeRequest::ReadContextPack { digest } => {
16223            // Read-only fetch of the pack's own bytes, keyed by content digest
16224            // rather than the caller's `SpawnContextId`: same no-controller-lease
16225            // rationale as `ResolveDurableContextPack` above, one step further
16226            // down the same catalog.
16227            let (id, byte_len, bytes) = shared
16228                .context_catalog
16229                .read()
16230                .unwrap_or_else(|poisoned| poisoned.into_inner())
16231                .get_by_digest(&digest)
16232                .map(|pack| {
16233                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16234                })
16235                .ok_or_else(|| {
16236                    failure(
16237                        NodeFailureCode::UnknownContextPack,
16238                        &format!("context pack {digest} not on this node"),
16239                    )
16240                })?;
16241            tracing::debug!(
16242                digest = digest.as_str(),
16243                byte_len,
16244                "read context pack bytes",
16245            );
16246            Ok(NodeResponse::ContextPackBytesRead {
16247                pack: ContextPackBytesRead {
16248                    digest,
16249                    id,
16250                    byte_len,
16251                    bytes,
16252                },
16253            })
16254        }
16255        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16256            let provider = controlled_session(shared, connection_id, role, &session)?;
16257            if let Some(prompt) = initial_prompt.as_deref() {
16258                validate_node_text("resume initial prompt", prompt)?;
16259            }
16260            let runtime_requirement = if initial_prompt.is_some() {
16261                ProviderRuntimeRequirement::ResumeWithPrompt
16262            } else {
16263                ProviderRuntimeRequirement::Resume
16264            };
16265            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16266            let runtime_policy = shared
16267                .admit_provider_runtime(&provider, runtime_requirement)
16268                .await?;
16269            let working_directory = shared.bound_workspace_root(&session)?;
16270            let request = ResumeLaunchRequest {
16271                working_directory,
16272                terminal_size,
16273                initial_prompt,
16274            };
16275            request
16276                .validate()
16277                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16278            let command = shared.prepare_command(ControlCommand::Resume {
16279                instance_id: session.session.instance_id,
16280                target: ResumeTarget::CurrentProvider,
16281                runtime_policy,
16282                request,
16283            });
16284            shared.arm_resume(&session, command.id, runtime_policy)?;
16285            let dispatch = shared.dispatch_envelope(command);
16286            if let Err(error) = dispatch {
16287                shared.clear_armed_resume(&session);
16288                return Err(error);
16289            }
16290            Ok(NodeResponse::Accepted)
16291        }
16292        NodeRequest::RenameSessionRecord {
16293            record_id,
16294            display_name,
16295        } => {
16296            shared.require_controller(connection_id, role)?;
16297            let record = shared.rename_session_record(&record_id, display_name)?;
16298            Ok(NodeResponse::SessionRecordUpdated { record })
16299        }
16300        NodeRequest::SetSessionTask {
16301            record_id,
16302            expected_revision,
16303            target,
16304        } => {
16305            shared.require_controller(connection_id, role)?;
16306            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16307            Ok(NodeResponse::SessionRecordUpdated { record })
16308        }
16309        NodeRequest::IndexProviderSession {
16310            workspace_id,
16311            provider,
16312            identity,
16313            display_name,
16314        } => {
16315            shared.require_controller(connection_id, role)?;
16316            let record = shared.index_provider_session(
16317                workspace_id,
16318                provider,
16319                identity,
16320                display_name,
16321            )?;
16322            Ok(NodeResponse::ProviderSessionIndexed { record })
16323        }
16324        NodeRequest::ResumeSessionRecord {
16325            record_id,
16326            terminal_size,
16327            initial_prompt,
16328        } => {
16329            shared.require_controller(connection_id, role)?;
16330            if let Some(prompt) = initial_prompt.as_deref() {
16331                validate_node_text("managed resume initial prompt", prompt)?;
16332            }
16333            let (record, session) = shared
16334                .resume_session_record(
16335                    &record_id,
16336                    terminal_size,
16337                    initial_prompt,
16338                )
16339                .await?;
16340            Ok(NodeResponse::SessionRecordResumed { record, session })
16341        }
16342        NodeRequest::ForgetSessionRecord { record_id } => {
16343            shared.require_controller(connection_id, role)?;
16344            shared.forget_session_record(&record_id).await?;
16345            Ok(NodeResponse::SessionRecordForgotten { record_id })
16346        }
16347        NodeRequest::Prompt { session, text } => {
16348            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16349            validate_node_text("prompt", &text)?;
16350            shared.require_session_runtime_policy(
16351                &session,
16352                ProviderRuntimeRequirement::SemanticPrompt,
16353            )?;
16354            let framing = prompt_framing(&agent_id);
16355            shared
16356                .dispatch_input_bounded(
16357                    &session,
16358                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16359                )
16360                .await?;
16361            Ok(NodeResponse::Accepted)
16362        }
16363        NodeRequest::Paste { session, text } => {
16364            controlled_session(shared, connection_id, role, &session)?;
16365            validate_node_text("paste", &text)?;
16366            shared.require_session_runtime_policy(
16367                &session,
16368                ProviderRuntimeRequirement::SemanticPrompt,
16369            )?;
16370            shared
16371                .dispatch_input_bounded(
16372                    &session,
16373                    InputAction::InsertDraft(PromptPayload {
16374                        text,
16375                        framing: PromptFraming::BracketedPaste,
16376                    }),
16377                )
16378                .await?;
16379            Ok(NodeResponse::Accepted)
16380        }
16381        NodeRequest::Input { session, text } => {
16382            controlled_session(shared, connection_id, role, &session)?;
16383            validate_node_text("terminal input", &text)?;
16384            shared
16385                .dispatch_input_bounded(
16386                    &session,
16387                    InputAction::TerminalText(TerminalText { text }),
16388                )
16389                .await?;
16390            Ok(NodeResponse::Accepted)
16391        }
16392        NodeRequest::TerminalBytes { session, bytes } => {
16393            controlled_session(shared, connection_id, role, &session)?;
16394            let action = terminal_bytes_action(bytes)?;
16395            shared
16396                .dispatch_input_bounded(&session, action)
16397                .await?;
16398            Ok(NodeResponse::Accepted)
16399        }
16400        NodeRequest::TerminalControl { session, control } => {
16401            controlled_session(shared, connection_id, role, &session)?;
16402            shared
16403                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16404                .await?;
16405            Ok(NodeResponse::Accepted)
16406        }
16407        NodeRequest::Resize { session, size } => {
16408            controlled_session(shared, connection_id, role, &session)?;
16409            if !size.is_valid() {
16410                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16411            }
16412            shared.dispatch_resize_bounded(&session, size).await?;
16413            Ok(NodeResponse::Accepted)
16414        }
16415        NodeRequest::Interrupt { session } => {
16416            controlled_session(shared, connection_id, role, &session)?;
16417            shared
16418                .dispatch_input_bounded(
16419                    &session,
16420                    InputAction::TerminalControl(TerminalControl::Interrupt),
16421                )
16422                .await?;
16423            Ok(NodeResponse::Accepted)
16424        }
16425        NodeRequest::Stop { session, force } => {
16426            controlled_session(shared, connection_id, role, &session)?;
16427            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16428            Ok(NodeResponse::Accepted)
16429        }
16430        NodeRequest::Remove { session } => {
16431            controlled_session(shared, connection_id, role, &session)?;
16432            shared.remove_session(&session).await?;
16433            Ok(NodeResponse::Accepted)
16434        }
16435        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16436            shared.require_controller(connection_id, role)?;
16437            let snapshot = shared.require_acp_session(&session)?;
16438            let interaction = snapshot
16439                .provider
16440                .interactions
16441                .iter()
16442                .find(|interaction| {
16443                    interaction_correlation(
16444                        session.session.instance_id,
16445                        session.session.generation,
16446                        interaction.id.0,
16447                    ) == correlation_id
16448                })
16449                .ok_or_else(|| failure(
16450                    NodeFailureCode::InvalidRequest,
16451                    "unknown interaction correlation",
16452                ))?;
16453            if interaction.status != ProviderInteractionStatus::Pending {
16454                return Err(failure(
16455                    NodeFailureCode::BackendOperationFailed,
16456                    "interaction has already been resolved",
16457                ));
16458            }
16459            response
16460                .validate_for(interaction.interaction_kind)
16461                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16462            let interaction_id = interaction.id;
16463            let response_kind = response.kind();
16464            let response_outcome = response.outcome();
16465            shared.dispatch(ControlCommand::ResolveInteraction {
16466                instance_id: session.session.instance_id,
16467                generation: session.session.generation,
16468                interaction_id,
16469                response,
16470            })?;
16471            // The one line that lets an operator compare what THIS node
16472            // recorded against what the provider itself reports doing --
16473            // measured live on a kimi run where our side resolved `true`
16474            // while the agent reported "rejected" and neither layer had a
16475            // line to line up against the other.
16476            tracing::info!(
16477                node_id = %shared.node_id,
16478                session = ?session.session,
16479                correlation_id = %correlation_id,
16480                interaction_id = interaction_id.0,
16481                response_kind = ?response_kind,
16482                outcome = ?response_outcome,
16483                "interaction resolved",
16484            );
16485            Ok(NodeResponse::Accepted)
16486        }
16487        NodeRequest::SetSessionMode { session, mode_id } => {
16488            shared.require_controller(connection_id, role)?;
16489            shared.require_acp_session(&session)?;
16490            shared.dispatch(ControlCommand::SetSessionMode {
16491                instance_id: session.session.instance_id,
16492                mode_id,
16493            })?;
16494            Ok(NodeResponse::Accepted)
16495        }
16496        NodeRequest::SetSessionConfigOption {
16497            session,
16498            option_id,
16499            value_json,
16500        } => {
16501            shared.require_controller(connection_id, role)?;
16502            shared.require_acp_session(&session)?;
16503            shared.dispatch(ControlCommand::SetSessionConfigOption {
16504                instance_id: session.session.instance_id,
16505                option_id,
16506                value_json,
16507            })?;
16508            Ok(NodeResponse::Accepted)
16509        }
16510        NodeRequest::SetSessionModel { session, model_id } => {
16511            shared.require_controller(connection_id, role)?;
16512            shared.require_acp_session(&session)?;
16513            shared.dispatch(ControlCommand::SetSessionModel {
16514                instance_id: session.session.instance_id,
16515                model_id,
16516            })?;
16517            Ok(NodeResponse::Accepted)
16518        }
16519        NodeRequest::Shutdown => {
16520            shared.require_controller(connection_id, role)?;
16521            shared.begin_shutdown_locked().await?;
16522            Ok(NodeResponse::ShuttingDown)
16523        }
16524    }
16525}
16526
16527fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16528    shared.require_controller(connection_id, role)?;
16529    shared.validate_address(session)
16530}
16531
16532/// Selects the sole history candidate matching `provider_session_id` when
16533/// the managed record carries a verified identity, or the sole candidate in
16534/// the discovered set at all when it does not (see
16535/// `export_context_pack_for_session_record_inner`'s identity match). Either
16536/// way, more than one match refuses instead of guessing which one is exact.
16537fn select_history_candidate(
16538    candidates: &[HistoryCandidateSummary],
16539    provider_session_id: Option<&str>,
16540) -> Result<String, NodeFailure> {
16541    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16542        Some(id) => candidate.session_id_hint == id,
16543        None => true,
16544    });
16545    let candidate = matches.next().ok_or_else(|| {
16546        failure(
16547            NodeFailureCode::SessionRecordConflict,
16548            if provider_session_id.is_some() {
16549                "no history candidate matches the managed provider session"
16550            } else {
16551                "no history candidate exists for the managed session"
16552            },
16553        )
16554    })?;
16555    if matches.next().is_some() {
16556        return Err(failure(
16557            NodeFailureCode::SessionRecordConflict,
16558            if provider_session_id.is_some() {
16559                "history identity is ambiguous for the managed provider session"
16560            } else {
16561                "history is ambiguous without a verified provider session identity"
16562            },
16563        ));
16564    }
16565    Ok(candidate.id.clone())
16566}
16567
16568fn resolved_spawn_profile_summary(
16569    profile: &SpawnProfileDefaults,
16570    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16571) -> Option<SpawnProfileSummary> {
16572    let environment_profile = match profile.environment_profile_id.as_ref() {
16573        None => None,
16574        Some(profile_id) => {
16575            let binding = environment_profiles.get(profile_id)?;
16576            if binding.provider != profile.provider
16577                || binding.native_profile_id(profile.mode).is_none()
16578            {
16579                return None;
16580            }
16581            Some(ResolvedEnvironmentProfileReceipt {
16582                profile_id: binding.id.clone(),
16583                profile_revision: binding.revision.clone(),
16584                network_allowlist: None,
16585                browser_profile_id: None,
16586            })
16587        }
16588    };
16589    Some(SpawnProfileSummary {
16590        id: profile.profile_id.clone(),
16591        revision: profile.revision.clone(),
16592        environment_profile,
16593    })
16594}
16595
16596/// True if `current` is an acceptable version of `expected` at export
16597/// revalidation/commit time.
16598///
16599/// When `allow_clean_detachment` is `false` (the on-demand,
16600/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16601/// be byte-identical to `expected` and still actively bound to `session` —
16602/// the original, unchanged invariant.
16603///
16604/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16605/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16606/// export reacts to produces on its own: `state` Live -> Dormant,
16607/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16608/// advancing. Every other field — including `provider_session`, `context`,
16609/// `bundle`, `task_binding` — must still match exactly. A record that changed
16610/// in any other way (rebind, a different active session, an identity
16611/// conflict, capacity eviction) is rejected either way; this widens nothing
16612/// about which sessions are eligible, only how the record's own expected
16613/// detachment is tolerated mid-export.
16614fn session_record_export_target_matches(
16615    expected: &ManagedSessionRecord,
16616    current: &ManagedSessionRecord,
16617    session: &SessionAddress,
16618    allow_clean_detachment: bool,
16619) -> bool {
16620    if current == expected && current.active_session.as_ref() == Some(session) {
16621        return current.state == ManagedSessionState::Live;
16622    }
16623    allow_clean_detachment
16624        && current.state == ManagedSessionState::Dormant
16625        && current.active_session.is_none()
16626        && current.record_id == expected.record_id
16627        && current.display_name == expected.display_name
16628        && current.provider == expected.provider
16629        && current.mode == expected.mode
16630        && current.workspace_id == expected.workspace_id
16631        && current.canonical_root == expected.canonical_root
16632        && current.provider_session == expected.provider_session
16633        && current.environment_profile == expected.environment_profile
16634        && current.bundle == expected.bundle
16635        && current.context_id == expected.context_id
16636        && current.context == expected.context
16637        && current.exported_context == expected.exported_context
16638        && current.task_binding == expected.task_binding
16639        && current.created_at_unix_ms == expected.created_at_unix_ms
16640        && current.last_error == expected.last_error
16641}
16642
16643fn session_record_context_export_binding_is_exact(
16644    expected: &ManagedSessionRecord,
16645    current: &ManagedSessionRecord,
16646    identity: Option<&ProviderSessionIdentity>,
16647    session: &SessionAddress,
16648    binding: &SessionBinding,
16649    provider: &AgentId,
16650    current_root: &str,
16651    allow_clean_detachment: bool,
16652) -> bool {
16653    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16654        && current.provider_session.as_ref() == identity
16655        && current.workspace_id == session.workspace_id
16656        && provider == &current.provider
16657        && binding.workspace_id == session.workspace_id
16658        && binding.generation == session.session.generation
16659        && binding.record_id.as_ref() == Some(&current.record_id)
16660        && platform::roots_equal(
16661            current_root,
16662            windows_path_text(&current.canonical_root),
16663        )
16664}
16665
16666fn session_record_context_export_source_is_usable(
16667    record: &ManagedSessionRecord,
16668    status: &SessionStatus,
16669    allow_clean_detachment: bool,
16670) -> bool {
16671    (record.state == ManagedSessionState::Live
16672        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16673        && context_pack_source_status_is_usable(status)
16674}
16675
16676fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16677    PromptFraming::BracketedPaste
16678}
16679
16680fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16681    if text.len() > MAX_NODE_TEXT_BYTES {
16682        return Err(failure(
16683            NodeFailureCode::InvalidRequest,
16684            &format!(
16685                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16686                text.len(),
16687            ),
16688        ));
16689    }
16690    Ok(())
16691}
16692
16693fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16694    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16695        return Err(failure(
16696            NodeFailureCode::InvalidRequest,
16697            &format!(
16698                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16699                bytes.len(),
16700            ),
16701        ));
16702    }
16703    Ok(())
16704}
16705
16706fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16707    validate_terminal_bytes(&bytes)?;
16708    Ok(InputAction::TerminalBytes(bytes))
16709}
16710
16711fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16712    if root.is_empty()
16713        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16714        || root.chars().any(char::is_control)
16715    {
16716        return Err(failure(
16717            NodeFailureCode::InvalidWorkspaceRoot,
16718            &format!(
16719                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16720            ),
16721        ));
16722    }
16723    Ok(())
16724}
16725
16726fn canonical_standalone_workspace_candidate(
16727    workspace_id: &WorkspaceId,
16728    root: &str,
16729) -> Result<String, NodeServerError> {
16730    let path = Path::new(root);
16731    match std::fs::symlink_metadata(path) {
16732        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16733            .map(|workspace| workspace.canonical_root),
16734        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16735            if !path.is_absolute() {
16736                return Err(NodeServerError::InvalidWorkspaceRoot {
16737                    workspace_id: workspace_id.clone(),
16738                    path: root.to_owned(),
16739                    message: "path is not absolute".to_owned(),
16740                });
16741            }
16742            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16743                workspace_id: workspace_id.clone(),
16744                path: root.to_owned(),
16745                message: "path has no standalone directory name".to_owned(),
16746            })?;
16747            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16748                workspace_id: workspace_id.clone(),
16749                path: root.to_owned(),
16750                message: "path has no parent directory".to_owned(),
16751            })?;
16752            let canonical = std::fs::canonicalize(parent)
16753                .map(|parent| parent.join(name))
16754                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16755                    workspace_id: workspace_id.clone(),
16756                    path: root.to_owned(),
16757                    message: error.to_string(),
16758                })?;
16759            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16760                NodeServerError::InvalidWorkspaceRoot {
16761                    workspace_id: workspace_id.clone(),
16762                    path: path.to_string_lossy().into_owned(),
16763                    message: "canonical path is not valid Unicode".to_owned(),
16764                }
16765            })?;
16766            let canonical_root = platform::normalize_canonical_root(canonical_root);
16767            validate_workspace_root(workspace_id, &canonical_root)?;
16768            Ok(canonical_root)
16769        }
16770        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16771            workspace_id: workspace_id.clone(),
16772            path: root.to_owned(),
16773            message: error.to_string(),
16774        }),
16775    }
16776}
16777
16778fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16779    let Some(revision) = revision else {
16780        return Ok(());
16781    };
16782    if revision.is_empty()
16783        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16784        || revision.chars().any(char::is_control)
16785    {
16786        return Err(failure(
16787            NodeFailureCode::InvalidRequest,
16788            &format!(
16789                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16790            ),
16791        ));
16792    }
16793    Ok(())
16794}
16795
16796fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16797    let code = match error.kind {
16798        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16799        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16800        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16801        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16802        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16803        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16804        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16805    };
16806    failure(code, &error.message)
16807}
16808
16809fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16810    let code = match error.kind {
16811        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16812        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16813        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16814            NodeFailureCode::BackendOperationFailed
16815        }
16816        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16817            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16818        }
16819    };
16820    failure(code, error.message)
16821}
16822
16823fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16824    let (code, message) = match error.kind {
16825        GitWorktreeErrorKind::Invalid => (
16826            NodeFailureCode::InvalidRequest,
16827            "managed Git input is invalid",
16828        ),
16829        GitWorktreeErrorKind::NotRepository => (
16830            NodeFailureCode::NotGitRepository,
16831            "managed source is not a Git repository",
16832        ),
16833        GitWorktreeErrorKind::Conflict => (
16834            NodeFailureCode::WorktreeConflict,
16835            "managed Git identity conflicts with existing repository state",
16836        ),
16837        GitWorktreeErrorKind::Protected => (
16838            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16839            "managed worktree ownership validation failed",
16840        ),
16841        GitWorktreeErrorKind::Dirty => (
16842            NodeFailureCode::WorktreeDirty,
16843            "managed worktree contains uncommitted changes",
16844        ),
16845        GitWorktreeErrorKind::Locked => (
16846            NodeFailureCode::WorktreeLocked,
16847            "managed worktree is locked",
16848        ),
16849        GitWorktreeErrorKind::Failed => (
16850            NodeFailureCode::BackendOperationFailed,
16851            "managed Git operation failed",
16852        ),
16853    };
16854    NodeFailure { code, message: message.to_owned() }
16855}
16856
16857fn managed_spawn_receipt(
16858    resolved: &ResolvedSpawnSpec,
16859    incarnation_id: NodeIncarnationId,
16860    session: SessionAddress,
16861    lease: ManagedWorktreeLeaseSnapshot,
16862    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16863    bundle: Option<ResolvedBundleReceipt>,
16864    context: Option<ResolvedContextPackReceipt>,
16865) -> ManagedWorktreeSpawnReceipt {
16866    let mut managed = resolved.clone();
16867    managed.target.worktree_id = Some(lease.workspace_id.clone());
16868    ManagedWorktreeSpawnReceipt {
16869        spawn: managed.receipt_with_materialization(
16870            incarnation_id,
16871            session,
16872            environment_profile,
16873            bundle,
16874            context,
16875        ),
16876        lease,
16877    }
16878}
16879
16880fn validate_selected_worktree(
16881    source_root: &str,
16882    selected_root: &str,
16883    worktrees: &[NativeGitWorktreeSnapshot],
16884) -> Result<(), NodeFailure> {
16885    if !worktrees
16886        .iter()
16887        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16888    {
16889        return Err(failure(
16890            NodeFailureCode::WorktreeProtected,
16891            "source workspace is not an authoritative non-bare Git worktree root",
16892        ));
16893    }
16894    if !worktrees.iter().any(|worktree| {
16895        worktree_paths_equal(&worktree.path, selected_root)
16896            && !worktree.is_main
16897            && !worktree.is_bare
16898    }) {
16899        return Err(failure(
16900            NodeFailureCode::WorktreeProtected,
16901            "selected workspace is not an eligible Git worktree in the source repository",
16902        ));
16903    }
16904    Ok(())
16905}
16906
16907fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16908    let code = match error.kind() {
16909        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16910        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16911            NodeFailureCode::RepositoryPathUnsafe
16912        }
16913        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16914        WorkspaceFileReadErrorKind::NotRegularFile => {
16915            NodeFailureCode::RepositoryFileNotRegular
16916        }
16917        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16918            NodeFailureCode::RepositoryFileReadFailed
16919        }
16920        WorkspaceFileReadErrorKind::RevisionConflict => {
16921            NodeFailureCode::RepositoryFileRevisionConflict
16922        }
16923        WorkspaceFileReadErrorKind::AlreadyExists
16924        | WorkspaceFileReadErrorKind::ParentNotFound
16925        | WorkspaceFileReadErrorKind::ParentNotDirectory
16926        | WorkspaceFileReadErrorKind::Canceled => {
16927            NodeFailureCode::RepositoryFileReadFailed
16928        }
16929    };
16930    failure(code, "repository file read failed")
16931}
16932
16933fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16934    let code = match error.kind() {
16935        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16936        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16937            NodeFailureCode::RepositoryPathUnsafe
16938        }
16939        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16940        WorkspaceFileReadErrorKind::NotRegularFile => {
16941            NodeFailureCode::RepositoryFileNotRegular
16942        }
16943        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16944            NodeFailureCode::RepositoryFileWriteFailed
16945        }
16946        WorkspaceFileReadErrorKind::RevisionConflict => {
16947            NodeFailureCode::RepositoryFileRevisionConflict
16948        }
16949        WorkspaceFileReadErrorKind::AlreadyExists
16950        | WorkspaceFileReadErrorKind::ParentNotFound
16951        | WorkspaceFileReadErrorKind::ParentNotDirectory
16952        | WorkspaceFileReadErrorKind::Canceled => {
16953            NodeFailureCode::RepositoryFileWriteFailed
16954        }
16955    };
16956    failure(code, "repository file write failed")
16957}
16958
16959async fn settle_workspace_entry_create<T>(
16960    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16961    commit_state: Arc<AtomicU8>,
16962    deadline: Duration,
16963    timeout_message: &'static str,
16964    task_failure_message: &'static str,
16965) -> Result<T, NodeFailure> {
16966    let joined = match timeout(deadline, &mut task).await {
16967        Ok(joined) => joined,
16968        Err(_) => {
16969            if commit_state
16970                .compare_exchange(
16971                    WORKSPACE_ENTRY_CREATE_PENDING,
16972                    WORKSPACE_ENTRY_CREATE_CANCELED,
16973                    Ordering::AcqRel,
16974                    Ordering::Acquire,
16975                )
16976                .is_ok()
16977            {
16978                return Err(failure(
16979                    NodeFailureCode::RepositoryEntryCreateTimedOut,
16980                    timeout_message,
16981                ));
16982            }
16983            debug_assert_eq!(
16984                commit_state.load(Ordering::Acquire),
16985                WORKSPACE_ENTRY_CREATE_COMMITTING,
16986            );
16987            task.await
16988        }
16989    };
16990    joined
16991        .map_err(|_| {
16992            failure(
16993                NodeFailureCode::RepositoryEntryCreateFailed,
16994                task_failure_message,
16995            )
16996        })?
16997        .map_err(workspace_entry_create_failure)
16998}
16999
17000fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17001    let code = match error.kind() {
17002        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17003        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17004            NodeFailureCode::RepositoryPathUnsafe
17005        }
17006        WorkspaceFileReadErrorKind::AlreadyExists => {
17007            NodeFailureCode::RepositoryEntryAlreadyExists
17008        }
17009        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17010            NodeFailureCode::RepositoryParentNotFound
17011        }
17012        WorkspaceFileReadErrorKind::ParentNotDirectory
17013        | WorkspaceFileReadErrorKind::NotRegularFile => {
17014            NodeFailureCode::RepositoryParentNotDirectory
17015        }
17016        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17017            NodeFailureCode::RepositoryEntryCreateFailed
17018        }
17019        WorkspaceFileReadErrorKind::RevisionConflict => {
17020            NodeFailureCode::RepositoryEntryCreateFailed
17021        }
17022        WorkspaceFileReadErrorKind::Canceled => {
17023            NodeFailureCode::RepositoryEntryCreateTimedOut
17024        }
17025    };
17026    failure(code, "repository entry create failed")
17027}
17028
17029fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17030    let value = digest(&SHA256, bytes)
17031        .as_ref()
17032        .iter()
17033        .map(|byte| format!("{byte:02x}"))
17034        .collect();
17035    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17036}
17037
17038fn managed_spawn_request_digest_v2(
17039    request: &ManagedWorktreeSpawnRequestV2,
17040) -> Result<String, NodeFailure> {
17041    let encoded = serde_json::to_vec(request).map_err(|_| {
17042        failure(
17043            NodeFailureCode::InvalidRequest,
17044            "managed worktree V2 request could not be canonicalized",
17045        )
17046    })?;
17047    let mut value = String::with_capacity(71);
17048    value.push_str("sha256:");
17049    for byte in digest(&SHA256, &encoded).as_ref() {
17050        use std::fmt::Write as _;
17051        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17052    }
17053    Ok(value)
17054}
17055
17056fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17057    let code = match error {
17058        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17059        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17060        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17061        NativeSessionPreviewError::UnsupportedProvider
17062        | NativeSessionPreviewError::PreviewUnavailable
17063        | NativeSessionPreviewError::SessionNotFound
17064        | NativeSessionPreviewError::AmbiguousSession => {
17065            NodeFailureCode::BackendOperationFailed
17066        }
17067    };
17068    failure(code, "native session preview failed")
17069}
17070
17071fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17072    let code = match error.kind() {
17073        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17074        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17075    };
17076    failure(code, "host directory browse failed")
17077}
17078
17079fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17080    let code = match error {
17081        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17082        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17083        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17084            NodeFailureCode::DeliveryStageConflict
17085        }
17086        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17087        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17088            NodeFailureCode::DeliveryChunkOutOfOrder
17089        }
17090        DeliveryStoreError::BlobDigestMismatch => {
17091            NodeFailureCode::DeliveryBlobDigestMismatch
17092        }
17093        DeliveryStoreError::BundleDigestMismatch => {
17094            NodeFailureCode::DeliveryBundleDigestMismatch
17095        }
17096        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17097        DeliveryStoreError::Unavailable
17098        | DeliveryStoreError::Corrupt
17099        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17100    };
17101    failure(code, "delivery operation failed")
17102}
17103
17104fn validate_workspace_root(
17105    workspace_id: &WorkspaceId,
17106    canonical_root: &str,
17107) -> Result<(), NodeServerError> {
17108    #[cfg(windows)]
17109    if canonical_root.starts_with(r"\\") {
17110        return Err(NodeServerError::InvalidWorkspaceRoot {
17111            workspace_id: workspace_id.clone(),
17112            path: canonical_root.to_owned(),
17113            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17114        });
17115    }
17116    if !platform::workspace_root_supported(canonical_root)
17117        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17118    {
17119        return Err(NodeServerError::InvalidWorkspaceRoot {
17120            workspace_id: workspace_id.clone(),
17121            path: canonical_root.to_owned(),
17122            message: "canonical path must be absolute, bounded, and free of control characters"
17123                .to_owned(),
17124        });
17125    }
17126    Ok(())
17127}
17128
17129fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17130    NodeFailure {
17131        code,
17132        message: node_failure_category(code).to_owned(),
17133    }
17134}
17135
17136fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17137    let code = match error {
17138        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17139        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17140        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17141        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17142        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17143        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17144        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17145        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17146        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17147    };
17148    failure(code, "harness MCP proxy operation failed")
17149}
17150
17151/// Numbers each harness-MCP overlay this process constructs, in
17152/// construction order, so two overlays built back to back in one node process
17153/// still get distinct trace files.
17154static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17155
17156/// Builds the optional trace environment entry of one harness-MCP overlay.
17157/// The caller's launch description names both the entry and the node-process
17158/// variable that opts in: when that variable holds a non-empty directory, the
17159/// entry names a trace file under it. Returns `None` when the launch has no
17160/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17161/// the directory; that is the operator's job.
17162fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17163    let trace = launch.trace.as_ref()?;
17164    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17165    if trace_dir.is_empty() {
17166        return None;
17167    }
17168    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17169    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17170    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17171}
17172
17173fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17174    let remaining = deadline.saturating_duration_since(Instant::now());
17175    if remaining.is_zero() {
17176        Err(failure(
17177            NodeFailureCode::SpawnDeadlineExceeded,
17178            "spawn deadline elapsed",
17179        ))
17180    } else {
17181        Ok(remaining)
17182    }
17183}
17184
17185fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17186    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17187    match deadline {
17188        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17189        None => Ok(bounded),
17190    }
17191}
17192
17193fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17194    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17195        failure(
17196            NodeFailureCode::SpawnDeadlineExceeded,
17197            "spawn dispatch exceeded the spawn deadline",
17198        )
17199    } else {
17200        error
17201    }
17202}
17203
17204fn spawn_runtime_capabilities(
17205    capabilities: &SpawnRequiredCapabilities,
17206) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17207    capabilities
17208        .iter()
17209        .map(|capability| match capability.as_str() {
17210            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17211            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17212            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17213            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17214                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17215            }
17216            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17217            _ => Err(failure(
17218                NodeFailureCode::UnsupportedSpawnCapability,
17219                "spawn required capability is unknown",
17220            )),
17221        })
17222        .collect()
17223}
17224
17225fn persistence_failure(_error: io::Error) -> NodeFailure {
17226    NodeFailure {
17227        code: NodeFailureCode::BackendOperationFailed,
17228        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17229    }
17230}
17231
17232fn node_failure_category(code: NodeFailureCode) -> &'static str {
17233    match code {
17234        NodeFailureCode::InvalidRequest => "invalid-request",
17235        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17236        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17237        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17238        NodeFailureCode::ReservationConflict => "reservation-conflict",
17239        NodeFailureCode::ReservationExpired => "reservation-expired",
17240        NodeFailureCode::BindingMismatch => "binding-mismatch",
17241        NodeFailureCode::NotActivated => "not-activated",
17242        NodeFailureCode::CallNotFound => "call-not-found",
17243        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17244        NodeFailureCode::ResponseTooLarge => "response-too-large",
17245        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17246        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17247        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17248        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17249        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17250        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17251        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17252        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17253        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17254        NodeFailureCode::Unauthorized => "unauthorized",
17255        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17256        NodeFailureCode::ControllerBusy => "controller-busy",
17257        NodeFailureCode::ControllerRequired => "controller-required",
17258        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17259        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17260        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17261        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17262        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17263        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17264        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17265        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17266        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17267        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17268        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17269        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17270        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17271        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17272        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17273        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17274        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17275        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17276        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17277        NodeFailureCode::GitReadFailed => "git-read-failed",
17278        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17279        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17280        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17281        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17282        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17283        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17284        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17285        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17286        NodeFailureCode::TurnInFlight => "turn-in-flight",
17287        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17288        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17289        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17290            "unsupported-network-allowlist-mapping"
17291        }
17292        NodeFailureCode::BrowserStationProbeUnavailable => {
17293            "browser-station-probe-unavailable"
17294        }
17295        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17296        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17297        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17298            "environment-profile-binding-mismatch"
17299        }
17300        NodeFailureCode::UnknownBundle => "unknown-bundle",
17301        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17302        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17303        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17304        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17305        NodeFailureCode::ContextPackMaterializationFailed => {
17306            "context-pack-materialization-failed"
17307        }
17308        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17309        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17310        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17311        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17312        NodeFailureCode::LastWorkspace => "last-workspace",
17313        NodeFailureCode::NotGitRepository => "not-git-repository",
17314        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17315        NodeFailureCode::WorktreeProtected => "worktree-protected",
17316        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17317        NodeFailureCode::WorktreeLocked => "worktree-locked",
17318        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17319        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17320        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17321        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17322            "managed-worktree-profile-revision-mismatch"
17323        }
17324        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17325        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17326            "standalone-workspace-recovery-required"
17327        }
17328        NodeFailureCode::UnknownSession => "unknown-session",
17329        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17330        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17331        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17332        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17333        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17334        NodeFailureCode::WorkspaceRegistrationRequired => {
17335            "workspace-registration-required"
17336        }
17337        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17338        NodeFailureCode::StaleGeneration => "stale-generation",
17339        NodeFailureCode::BackendBusy => "backend-busy",
17340        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17341        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17342        NodeFailureCode::ShuttingDown => "shutting-down",
17343    }
17344}
17345
17346fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17347    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17348}
17349
17350fn durable_state_load_error(error: io::Error) -> NodeServerError {
17351    let category = match session_registry::state_load_refusal(&error) {
17352        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17353            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17354        }
17355        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17356            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17357        }
17358        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17359            DURABLE_STATE_CONFLICT_ERROR
17360        }
17361        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17362    };
17363    durable_state_server_error(error, category)
17364}
17365
17366fn unix_time_ms() -> u64 {
17367    std::time::SystemTime::now()
17368        .duration_since(std::time::UNIX_EPOCH)
17369        .unwrap_or_default()
17370        .as_millis()
17371        .min(u64::MAX as u128) as u64
17372}
17373
17374fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17375    if record.provider_session.is_some() {
17376        ManagedSessionState::Dormant
17377    } else {
17378        ManagedSessionState::Unavailable
17379    }
17380}
17381
17382fn active_record_state(
17383    identity_admitted: bool,
17384    provider_session_is_known: bool,
17385) -> ManagedSessionState {
17386    if identity_admitted && !provider_session_is_known {
17387        ManagedSessionState::IdentityPending
17388    } else {
17389        ManagedSessionState::Live
17390    }
17391}
17392
17393fn managed_worktree_record_holders(
17394    records: &[ManagedSessionRecord],
17395) -> Vec<(SessionRecordId, WorkspaceId)> {
17396    records
17397        .iter()
17398        .filter(|record| record.provider_session.is_some())
17399        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17400        .collect()
17401}
17402
17403fn managed_spawn_record_ownership(
17404    binding: &SessionBinding,
17405    effective_runtime_policy: ProviderRuntimePolicy,
17406) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17407    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17408    if effective_runtime_policy.provider_session_identity {
17409        (binding.record_id.clone(), None)
17410    } else {
17411        (None, binding.record_id.clone())
17412    }
17413}
17414
17415#[derive(Debug, Error)]
17416pub enum NodeServerError {
17417    #[error("node endpoint must be a bounded absolute local transport path")]
17418    InvalidEndpoint,
17419    #[error("node access token must contain 1..=4096 bytes")]
17420    InvalidAccessToken,
17421    #[error("node HTTP observer must listen on a loopback address: {0}")]
17422    InvalidApiListen(std::net::SocketAddr),
17423    #[error("node browser bridge must listen on a loopback address: {0}")]
17424    InvalidBridgeListen(std::net::SocketAddr),
17425    #[error("node browser bridge token must contain 1..=4096 bytes")]
17426    InvalidBridgeToken,
17427    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17428    BridgeUnderlayRequiresBridgeListen,
17429    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17430    BridgeUnderlayRequiresBridgeToken,
17431    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17432    InvalidBridgeUnderlayToken,
17433    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17434    InvalidBridgeUnderlayKey,
17435    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17436    InvalidCallHome(std::net::SocketAddr),
17437    #[error("node could not reach its call-home relay: {0}")]
17438    CallHomeTransport(#[source] std::io::Error),
17439    #[error("node durable state path must be an absolute file path: {0}")]
17440    InvalidStatePath(String),
17441    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17442    InvalidHarnessMcpHelper,
17443    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17444    InvalidNetworkAllowlistCatalogPath(String),
17445    #[error("network allowlist catalog could not be read: {0}")]
17446    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17447    #[error("network allowlist catalog line {line}: {message}")]
17448    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17449    #[error("network allowlist catalog: {0}")]
17450    InvalidNetworkAllowlistCatalog(String),
17451    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17452    NetworkAllowlistCatalogCapacity { max: usize },
17453    #[error("the local state directory is unavailable; supply an explicit state path")]
17454    LocalStateDirectoryUnavailable,
17455    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17456    LocalRuntimeDirectoryUnavailable,
17457    #[error("node durable state failed: {0}")]
17458    DurableState(io::Error),
17459    #[error("node requires at least one configured workspace")]
17460    NoWorkspaces,
17461    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17462    InvalidWorkspaceRoot {
17463        workspace_id: WorkspaceId,
17464        path: String,
17465        message: String,
17466    },
17467    #[error("duplicate workspace ID: {0}")]
17468    DuplicateWorkspaceId(WorkspaceId),
17469    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17470    DuplicateWorkspaceRoot {
17471        first: WorkspaceId,
17472        second: WorkspaceId,
17473        root: String,
17474    },
17475    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17476    ManagedWorktreeProfileRequired(WorkspaceId),
17477    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17478    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17479    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17480    DuplicateManagedWorktreeProfile {
17481        workspace_id: WorkspaceId,
17482        profile_id: WorktreeProfileId,
17483    },
17484    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17485    ManagedWorktreeProfileCapacity {
17486        workspace_id: WorkspaceId,
17487        max: usize,
17488    },
17489    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17490    InvalidManagedWorktreeProfile {
17491        workspace_id: WorkspaceId,
17492        profile_id: WorktreeProfileId,
17493        message: String,
17494    },
17495    #[error("duplicate node environment profile: {0}")]
17496    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17497    #[error("node environment profile provider is unavailable: {0}")]
17498    EnvironmentProfileProviderUnavailable(AgentId),
17499    #[error("node environment profile capacity is {max}")]
17500    EnvironmentProfileCapacity { max: usize },
17501    #[error("node environment profiles reuse native profile ID '{0}'")]
17502    DuplicateNativeEnvironmentProfile(String),
17503    #[error("node native environment profile is invalid: {0}")]
17504    NativeEnvironmentProfile(String),
17505    #[error("node session-environment materialization root must be absolute")]
17506    InvalidSessionEnvironmentRoot,
17507    #[error("node session-environment materializer is unavailable")]
17508    SessionEnvironmentMaterializerRequired,
17509    #[error("node session-environment materializer failed to initialize")]
17510    SessionEnvironmentMaterializer,
17511    #[error("node bundle catalog is invalid: {0}")]
17512    BundleCatalog(String),
17513    #[error("node delivery store failed to initialize: {0}")]
17514    DeliveryStore(String),
17515    #[error("node context pack store failed to initialize")]
17516    ContextPackStore,
17517    #[error("active agent registry failed: {0}")]
17518    Registry(String),
17519    #[error("node provider contract manifest is invalid: {0}")]
17520    ProviderContractManifest(String),
17521    #[error("named pipe I/O failed: {0}")]
17522    Io(#[from] io::Error),
17523    #[error("node HTTP observer failed: {0}")]
17524    HttpApi(io::Error),
17525    #[error("node browser bridge failed: {0}")]
17526    BridgeApi(io::Error),
17527    #[error(transparent)]
17528    Frame(#[from] FrameError),
17529    #[error("node handshake failed: {0}")]
17530    Handshake(String),
17531    #[error("node authentication frame was not received before the bounded deadline")]
17532    AuthenticationTimedOut,
17533    #[error("node authentication primitive failed: {0}")]
17534    Authentication(String),
17535    #[error("node incarnation identity generation failed: {0}")]
17536    IncarnationIdentity(String),
17537    #[error("node pre-authentication limiter is closed")]
17538    PreauthClosed,
17539    #[error("node authenticated connection limit was reached")]
17540    AuthenticatedConnectionLimit,
17541    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17542    SignalStreamClosed,
17543    #[error("node stop-all dispatch failed: {0:?}")]
17544    ShutdownDispatch(NodeFailure),
17545    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17546    ShutdownTimedOut { active_native_sessions: usize },
17547}
17548
17549impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17550    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17551        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17552        match error {
17553            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17554            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17555            E::InvalidEntry { line, message } => {
17556                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17557            }
17558            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17559            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17560        }
17561    }
17562}
17563
17564#[cfg(test)]
17565mod agent_stream_blocked_tests {
17566    use super::*;
17567    use gate4agent_types::{
17568        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17569        ProviderSource,
17570    };
17571
17572    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17573        ProviderSource {
17574            family,
17575            binding: AdapterBinding::new(
17576                AdapterId::new(adapter).unwrap(),
17577                "fixture/v1",
17578                AdapterVerification::SyntheticFixture,
17579            )
17580            .unwrap(),
17581        }
17582    }
17583
17584    fn provider_control_event_at(
17585        family: AdapterFamily,
17586        adapter: &str,
17587        source_sequence: u64,
17588        provider_event: ProviderEvent,
17589    ) -> ControlEvent {
17590        ControlEvent {
17591            sequence: 41,
17592            command_id: None,
17593            instance_id: AgentInstanceId(7),
17594            generation: SessionGeneration(3),
17595            event: ControlEventKind::ProviderEvent {
17596                sequence: 8,
17597                source: provider_source(family, adapter),
17598                source_sequence,
17599                event: provider_event,
17600            },
17601        }
17602    }
17603
17604    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17605        provider_control_event_at(family, "fixture", 9, provider_event)
17606    }
17607
17608    fn denied_host_request(
17609        by: ProviderHostDecisionAuthority,
17610        reason: Option<String>,
17611    ) -> ControlEvent {
17612        provider_control_event_at(
17613            AdapterFamily::Acp,
17614            "claude-code",
17615            2,
17616            ProviderEvent::HostRequestObserved {
17617                method: "terminal/create".to_owned(),
17618                params_json: String::new(),
17619                decision: ProviderHostRequestDecision::Denied { by },
17620                outcome: ProviderHostRequestOutcome::Executed,
17621                reason,
17622            },
17623        )
17624    }
17625
17626    fn blocked(
17627        event: &ControlEvent,
17628    ) -> (
17629        Option<String>,
17630        String,
17631        BlockAuthorityV1,
17632        Option<String>,
17633        String,
17634        Option<String>,
17635    ) {
17636        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17637        chunk.validate().expect("minted chunk must validate");
17638        let AgentStreamChunkKindV1::Blocked {
17639            correlation_id,
17640            tool_class,
17641            authority,
17642            reason_kind,
17643            reason,
17644            help,
17645        } = chunk.kind
17646        else {
17647            panic!("expected AgentStreamChunkKindV1::Blocked");
17648        };
17649        (correlation_id, tool_class, authority, reason_kind, reason, help)
17650    }
17651
17652    /// Pins the fleet and every adapter each member declares: the fleet is
17653    /// exactly what `active_registry` says, and no member declares a hook
17654    /// adapter (lifecycle hooks are retired).
17655    #[test]
17656    fn the_enabled_provider_matrix_is_exact() {
17657        let registry = active_registry().unwrap();
17658        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17659        assert_eq!(
17660            providers
17661                .iter()
17662                .map(|contract| contract.provider.as_str())
17663                .collect::<Vec<_>>(),
17664            vec!["claude", "codex", "grok", "kimi"],
17665        );
17666        assert!(adapters.iter().all(|contract| {
17667            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17668        }));
17669    }
17670
17671    /// A gate-denied host request, WITH its reason text carried all the way
17672    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17673    #[test]
17674    fn gate_denied_host_request_mints_a_blocked_chunk() {
17675        let gate_text =
17676            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17677        let event = denied_host_request(
17678            ProviderHostDecisionAuthority::Gate,
17679            Some(gate_text.to_owned()),
17680        );
17681        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17682        assert_eq!(chunk.source_sequence, 2);
17683        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17684        assert_eq!(authority, BlockAuthorityV1::HostGate);
17685        assert_eq!(reason, gate_text);
17686        assert_eq!(correlation_id, None);
17687        assert_eq!(reason_kind, None);
17688        assert_eq!(help, None);
17689        assert_eq!(tool_class, "Write");
17690    }
17691
17692    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.";
17693
17694    /// The owner's classifier sample, verbatim, arriving as a
17695    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17696    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17697    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17698    /// and the guidance tail as `help`.
17699    #[test]
17700    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17701        let event = provider_control_event(
17702            AdapterFamily::Acp,
17703            ProviderEvent::ToolCompleted {
17704                id: "toolu_9".to_owned(),
17705                output: CLASSIFIER_SAMPLE.to_owned(),
17706                is_error: true,
17707                duration_ms: None,
17708                agent_id: None,
17709                non_execution_kind: Some("permission-rule".to_owned()),
17710            },
17711        );
17712        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17713        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17714        assert_eq!(reason, "Blocked by classifier");
17715        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17716        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17717        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17718    }
17719
17720    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17721    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17722    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17723    /// vendor code as `reason_kind`.
17724    #[test]
17725    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17726        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.";
17727        let event = provider_control_event(
17728            AdapterFamily::Acp,
17729            ProviderEvent::SessionEnded {
17730                result: "JSON-RPC error -32603: Internal error".to_owned(),
17731                cost_usd: None,
17732                is_error: true,
17733                stop_reason: Some(ProviderStopReason::ProviderError {
17734                    code: -32603,
17735                    message: message.to_owned(),
17736                    vendor_code: Some("usageLimitExceeded".to_owned()),
17737                }),
17738            },
17739        );
17740        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17741        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17742        assert_eq!(reason, message);
17743        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17744        assert_eq!(tool_class, "Session");
17745    }
17746
17747    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17748    /// `authority: ProviderRefusal`.
17749    #[test]
17750    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17751        let event = provider_control_event(
17752            AdapterFamily::Acp,
17753            ProviderEvent::SessionEnded {
17754                result: "refusal".to_owned(),
17755                cost_usd: None,
17756                is_error: true,
17757                stop_reason: Some(ProviderStopReason::Refusal),
17758            },
17759        );
17760        let (_, _, authority, _, _, _) = blocked(&event);
17761        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17762    }
17763
17764    /// A failed tool completion with no typed `non_execution_kind` -- or one
17765    /// that only says the turn stopped -- is a plain execution failure, never a
17766    /// block; nor is an agent's own narrative claiming one.
17767    #[test]
17768    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17769        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17770            let event = provider_control_event(
17771                AdapterFamily::Acp,
17772                ProviderEvent::ToolCompleted {
17773                    id: "toolu_1".to_owned(),
17774                    output: "command exited with status 1".to_owned(),
17775                    is_error: true,
17776                    duration_ms: Some(12),
17777                    agent_id: None,
17778                    non_execution_kind,
17779                },
17780            );
17781            assert!(agent_stream_chunk(&event).is_none());
17782        }
17783        let narrative = provider_control_event(
17784            AdapterFamily::Acp,
17785            ProviderEvent::ToolCompleted {
17786                id: "toolu_2".to_owned(),
17787                output: "the harness blocked the write, approval needed".to_owned(),
17788                is_error: false,
17789                duration_ms: Some(3),
17790                agent_id: None,
17791                non_execution_kind: None,
17792            },
17793        );
17794        assert!(agent_stream_chunk(&narrative).is_none());
17795    }
17796
17797    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17798    /// execution outcome: a granted request that failed while running was
17799    /// still authorized.
17800    #[test]
17801    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17802        for (decision, outcome) in [
17803            (
17804                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17805                ProviderHostRequestOutcome::Executed,
17806            ),
17807            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17808            (
17809                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17810                ProviderHostRequestOutcome::Failed {
17811                    error: "terminal/create spawn failed: os error 3".to_owned(),
17812                },
17813            ),
17814        ] {
17815            let event = provider_control_event_at(
17816                AdapterFamily::Acp,
17817                "claude-code",
17818                3,
17819                ProviderEvent::HostRequestObserved {
17820                    method: "fs/read_text_file".to_owned(),
17821                    params_json: String::new(),
17822                    decision: decision.clone(),
17823                    outcome,
17824                    reason: None,
17825                },
17826            );
17827            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17828        }
17829    }
17830
17831    /// A deferred `session/request_permission` resolved by an operator or an
17832    /// expired deadline carries no reason text -- the chunk falls back to a
17833    /// categorical sentence naming the authority rather than inventing detail.
17834    #[test]
17835    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17836        for (by, expected_authority, expected_reason) in [
17837            (
17838                ProviderHostDecisionAuthority::Operator,
17839                BlockAuthorityV1::Operator,
17840                "declined by the operator",
17841            ),
17842            (
17843                ProviderHostDecisionAuthority::DeadlinePolicy,
17844                BlockAuthorityV1::HostDeadline,
17845                "denied by host policy after the operator deadline expired",
17846            ),
17847        ] {
17848            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17849            assert_eq!(authority, expected_authority);
17850            assert_eq!(reason, expected_reason);
17851        }
17852    }
17853
17854    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17855    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17856    /// outright rather than truncating.
17857    #[test]
17858    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17859        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17860        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17861            ProviderHostDecisionAuthority::Policy,
17862            Some(oversized),
17863        ));
17864        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17865    }
17866}
17867
17868#[cfg(test)]
17869mod standalone_capability_platform_tests {
17870    use super::*;
17871
17872    #[test]
17873    fn standalone_workspace_capability_requires_windows_identity_guards() {
17874        let capabilities = baseline_capabilities().unwrap();
17875        let advertised = capabilities.iter().any(|capability| {
17876            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17877        });
17878        assert_eq!(advertised, cfg!(windows));
17879
17880        let request = NodeRequest::CreateStandaloneWorkspace {
17881            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17882            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17883            initial_branch: None,
17884        };
17885        assert_eq!(
17886            request_uses_unnegotiated_capability(&request, &capabilities),
17887            !cfg!(windows),
17888        );
17889    }
17890}
17891
17892
17893#[cfg(test)]
17894mod station_knobs_resolve_tests {
17895    use super::*;
17896    use gate4agent_catalog::EnvMutation;
17897    use gate4agent_runtime_native::{
17898        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17899        NativeLaunchProfileId,
17900    };
17901    use std::ffi::OsString;
17902    use std::sync::Arc;
17903
17904    struct EmptyEnvironmentResolver;
17905
17906    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17907        fn resolve_child_environment(
17908            &self,
17909        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17910            Ok(vec![EnvMutation {
17911                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17912                value: None,
17913            }])
17914        }
17915    }
17916
17917    fn agent(value: &str) -> AgentId {
17918        AgentId::new(value).unwrap()
17919    }
17920
17921    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17922        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17923        let profile_revision =
17924            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17925        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17926            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17927            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17928            provider: agent("claude"),
17929            mode: SessionMode::Pty,
17930            terminal_size: gate4agent_types::TerminalSize {
17931                rows: 24,
17932                columns: 80,
17933            },
17934            prompt: None,
17935            bundle_id: None,
17936            context_id: None,
17937            environment_profile_id: Some(profile_id.clone()),
17938        };
17939        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17940        let catalog = active_registry().unwrap();
17941        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17942        let control = runtime.native_launch_profile_control();
17943        let node_profile = NodeEnvironmentProfile::new(
17944            profile_id.clone(),
17945            profile_revision,
17946            agent("claude"),
17947            [NativeLaunchProfile::new(
17948                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17949                agent("claude"),
17950                TransportKind::Pty,
17951                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17952                Arc::new(EmptyEnvironmentResolver),
17953            )
17954            .unwrap()],
17955        )
17956        .unwrap();
17957        let (binding, native_profiles, _materialization) = node_profile.into_parts();
17958        for native_profile in native_profiles {
17959            runtime.upsert_native_launch_profile(native_profile).unwrap();
17960        }
17961        let workspace = WorkspaceConfig::new(
17962            WorkspaceId::new("primary").unwrap(),
17963            std::env::current_dir().unwrap(),
17964        )
17965        .unwrap();
17966        let shared = NodeShared::new_with_incarnation(
17967            handle,
17968            "fixture-token".to_owned(),
17969            NodeId::new("node-station-knobs-test").unwrap(),
17970            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17971            vec![workspace],
17972            vec![agent("claude")],
17973            ProviderRuntimeStatuses::default(),
17974            None,
17975            Vec::new(),
17976            Vec::new(),
17977            spawn_profiles,
17978            Some(control),
17979            None,
17980            None,
17981            None,
17982            Vec::new(),
17983            Vec::new(),
17984            Vec::new(),
17985            Vec::new(),
17986            None,
17987            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17988        );
17989        shared
17990            .environment_profiles
17991            .write()
17992            .unwrap_or_else(|poisoned| poisoned.into_inner())
17993            .insert(binding.id.clone(), binding);
17994        (shared, spawn_defaults)
17995    }
17996
17997    /// Empty-default catalog refuses unknown network_allowlist ids.
17998    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
17999    #[test]
18000    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18001        let (shared, defaults) = station_knobs_fixture();
18002        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18003        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18004        let spec = crate::protocol::SpawnSpec {
18005            target: crate::protocol::SpawnTarget {
18006                node_id: shared.node_id.clone(),
18007                workspace_id: WorkspaceId::new("primary").unwrap(),
18008                worktree_id: None,
18009            },
18010            profile_id: defaults.profile_id.clone(),
18011            expected_profile_revision: defaults.revision.clone(),
18012            overrides: crate::protocol::SpawnOverrides {
18013                network_allowlist: Some(unknown),
18014                browser_profile_id: Some(browser),
18015                ..crate::protocol::SpawnOverrides::default()
18016            },
18017            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18018            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18019                "station-knobs-unknown-allowlist",
18020            )
18021            .unwrap(),
18022            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18023        };
18024        let resolved = spec.resolve(&defaults).unwrap();
18025        let failure = shared
18026            .resolve_environment_profile(&resolved)
18027            .expect_err("unknown allowlist must refuse");
18028        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18029    }
18030
18031    /// Registered network_allowlist (+ browser_profile_id when feature off)
18032    /// echo onto the env-profile receipt. Empty catalog still allows omit
18033    /// (None). Empty/whitespace refuse is type-level. Feature-off
18034    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18035    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18036    #[test]
18037    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18038        let (shared, defaults) = station_knobs_fixture();
18039        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18040        #[cfg(not(feature = "dig2-station-probe"))]
18041        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18042        shared
18043            .network_allowlist_catalog
18044            .write()
18045            .unwrap_or_else(|poisoned| poisoned.into_inner())
18046            .insert_id(allowlist.clone())
18047            .unwrap();
18048        let spec = crate::protocol::SpawnSpec {
18049            target: crate::protocol::SpawnTarget {
18050                node_id: shared.node_id.clone(),
18051                workspace_id: WorkspaceId::new("primary").unwrap(),
18052                worktree_id: None,
18053            },
18054            profile_id: defaults.profile_id.clone(),
18055            expected_profile_revision: defaults.revision.clone(),
18056            overrides: crate::protocol::SpawnOverrides {
18057                network_allowlist: Some(allowlist.clone()),
18058                #[cfg(not(feature = "dig2-station-probe"))]
18059                browser_profile_id: Some(browser.clone()),
18060                ..crate::protocol::SpawnOverrides::default()
18061            },
18062            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18063            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18064                .unwrap(),
18065            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18066        };
18067        let resolved = spec.resolve(&defaults).unwrap();
18068        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18069        #[cfg(not(feature = "dig2-station-probe"))]
18070        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18071        let receipt = shared
18072            .resolve_environment_profile(&resolved)
18073            .expect("resolve ok")
18074            .expect("env profile present");
18075        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18076        #[cfg(not(feature = "dig2-station-probe"))]
18077        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18078        #[cfg(feature = "dig2-station-probe")]
18079        assert_eq!(receipt.browser_profile_id, None);
18080        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18081
18082        let omitted = crate::protocol::SpawnSpec {
18083            overrides: crate::protocol::SpawnOverrides::default(),
18084            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18085                .unwrap(),
18086            ..spec
18087        };
18088        let resolved_omit = omitted.resolve(&defaults).unwrap();
18089        let bare = shared
18090            .resolve_environment_profile(&resolved_omit)
18091            .expect("resolve ok")
18092            .expect("env profile present");
18093        assert_eq!(bare.network_allowlist, None);
18094        assert_eq!(bare.browser_profile_id, None);
18095    }
18096
18097    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18098    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18099    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18100    /// unit-tested separately (probe refuses before lease on Linux).
18101    #[cfg(feature = "dig2-station-probe")]
18102    #[test]
18103    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18104        let (shared, defaults) = station_knobs_fixture();
18105        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18106        let spec = crate::protocol::SpawnSpec {
18107            target: crate::protocol::SpawnTarget {
18108                node_id: shared.node_id.clone(),
18109                workspace_id: WorkspaceId::new("primary").unwrap(),
18110                worktree_id: None,
18111            },
18112            profile_id: defaults.profile_id.clone(),
18113            expected_profile_revision: defaults.revision.clone(),
18114            overrides: crate::protocol::SpawnOverrides {
18115                browser_profile_id: Some(browser),
18116                ..crate::protocol::SpawnOverrides::default()
18117            },
18118            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18119            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18120                "station-knobs-probe-refuse",
18121            )
18122            .unwrap(),
18123            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18124        };
18125        let resolved = spec.resolve(&defaults).unwrap();
18126        let failure = shared
18127            .resolve_environment_profile(&resolved)
18128            .expect_err("browser_profile with failing probe must refuse");
18129        #[cfg(windows)]
18130        {
18131            assert!(
18132                matches!(
18133                    failure.code,
18134                    NodeFailureCode::BrowserStationUnreachable
18135                        | NodeFailureCode::BrowserStationProbeUnavailable
18136                ),
18137                "unexpected code {:?}",
18138                failure.code
18139            );
18140        }
18141        #[cfg(not(windows))]
18142        {
18143            assert_eq!(
18144                failure.code,
18145                NodeFailureCode::BrowserStationProbeUnavailable
18146            );
18147        }
18148    }
18149
18150    /// Feature on: omitting browser_profile_id still resolves (no probe).
18151    #[cfg(feature = "dig2-station-probe")]
18152    #[test]
18153    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18154        let (shared, defaults) = station_knobs_fixture();
18155        let spec = crate::protocol::SpawnSpec {
18156            target: crate::protocol::SpawnTarget {
18157                node_id: shared.node_id.clone(),
18158                workspace_id: WorkspaceId::new("primary").unwrap(),
18159                worktree_id: None,
18160            },
18161            profile_id: defaults.profile_id.clone(),
18162            expected_profile_revision: defaults.revision.clone(),
18163            overrides: crate::protocol::SpawnOverrides::default(),
18164            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18165            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18166                "station-knobs-probe-omit",
18167            )
18168            .unwrap(),
18169            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18170        };
18171        let resolved = spec.resolve(&defaults).unwrap();
18172        let receipt = shared
18173            .resolve_environment_profile(&resolved)
18174            .expect("omit browser_profile must not probe")
18175            .expect("env profile present");
18176        assert_eq!(receipt.browser_profile_id, None);
18177    }
18178
18179    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18180    /// refuses a second holder for the same opaque profile id; release on
18181    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18182    #[cfg(feature = "dig2-station-probe")]
18183    #[test]
18184    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18185        let (shared, _defaults) = station_knobs_fixture();
18186        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18187        let workspace_id = WorkspaceId::new("primary").unwrap();
18188        let address_a = SessionAddress {
18189            workspace_id: workspace_id.clone(),
18190            session: SessionKey {
18191                instance_id: AgentInstanceId(101),
18192                generation: SessionGeneration(1),
18193            },
18194        };
18195        let address_b = SessionAddress {
18196            workspace_id: workspace_id.clone(),
18197            session: SessionKey {
18198                instance_id: AgentInstanceId(102),
18199                generation: SessionGeneration(1),
18200            },
18201        };
18202        let guard_a = crate::dig2_station_lease::acquire_lease(
18203            &shared.browser_station_leases,
18204            profile.clone(),
18205            address_a.session.instance_id,
18206            1,
18207        )
18208        .expect("first lease");
18209        let busy = crate::dig2_station_lease::acquire_lease(
18210            &shared.browser_station_leases,
18211            profile.clone(),
18212            address_b.session.instance_id,
18213            2,
18214        )
18215        .expect_err("second holder must refuse");
18216        assert!(matches!(
18217            busy,
18218            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18219        ));
18220        // Simulate a bound session so remove_binding clears the lease.
18221        shared.bind_session_with_policy(
18222            &address_a,
18223            ProviderRuntimePolicy::raw_pty(),
18224            None,
18225        );
18226        guard_a.retain();
18227        assert!(shared.remove_binding(&address_a).is_some());
18228        crate::dig2_station_lease::acquire_lease(
18229            &shared.browser_station_leases,
18230            profile,
18231            address_b.session.instance_id,
18232            3,
18233        )
18234        .expect("reacquire after clear")
18235        .retain();
18236        assert!(
18237            crate::dig2_station_lease::release_lease_for_holder(
18238                &shared.browser_station_leases,
18239                address_b.session.instance_id,
18240            )
18241        );
18242    }
18243
18244    /// Feature-off: no browser_station_leases field; stub echo documented in
18245    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18246    #[cfg(not(feature = "dig2-station-probe"))]
18247    #[test]
18248    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18249        let (shared, defaults) = station_knobs_fixture();
18250        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18251        let spec = crate::protocol::SpawnSpec {
18252            target: crate::protocol::SpawnTarget {
18253                node_id: shared.node_id.clone(),
18254                workspace_id: WorkspaceId::new("primary").unwrap(),
18255                worktree_id: None,
18256            },
18257            profile_id: defaults.profile_id.clone(),
18258            expected_profile_revision: defaults.revision.clone(),
18259            overrides: crate::protocol::SpawnOverrides {
18260                browser_profile_id: Some(browser.clone()),
18261                ..crate::protocol::SpawnOverrides::default()
18262            },
18263            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18264            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18265                .unwrap(),
18266            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18267        };
18268        let resolved = spec.resolve(&defaults).unwrap();
18269        let receipt = shared
18270            .resolve_environment_profile(&resolved)
18271            .expect("stub resolve")
18272            .expect("env profile");
18273        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18274    }
18275
18276
18277    /// Startup file loader registers ids into the empty-default catalog.
18278    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18279    #[test]
18280    fn network_allowlist_catalog_loads_from_temp_file() {
18281        let unique = std::time::SystemTime::now()
18282            .duration_since(std::time::UNIX_EPOCH)
18283            .unwrap()
18284            .as_nanos();
18285        let path = std::env::temp_dir().join(format!(
18286            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18287            std::process::id(),
18288        ));
18289        std::fs::write(
18290            &path,
18291            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18292        )
18293        .unwrap();
18294        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18295            .expect("temp catalog file must load");
18296        let _ = std::fs::remove_file(&path);
18297        assert_eq!(loaded.len(), 2);
18298        let (shared, defaults) = station_knobs_fixture();
18299        {
18300            let mut catalog = shared
18301                .network_allowlist_catalog
18302                .write()
18303                .unwrap_or_else(|poisoned| poisoned.into_inner());
18304            *catalog = loaded;
18305        }
18306        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18307        let spec = crate::protocol::SpawnSpec {
18308            target: crate::protocol::SpawnTarget {
18309                node_id: shared.node_id.clone(),
18310                workspace_id: WorkspaceId::new("primary").unwrap(),
18311                worktree_id: None,
18312            },
18313            profile_id: defaults.profile_id.clone(),
18314            expected_profile_revision: defaults.revision.clone(),
18315            overrides: crate::protocol::SpawnOverrides {
18316                network_allowlist: Some(allowlist.clone()),
18317                ..crate::protocol::SpawnOverrides::default()
18318            },
18319            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18320            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18321                "station-knobs-catalog-file",
18322            )
18323            .unwrap(),
18324            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18325        };
18326        let resolved = spec.resolve(&defaults).unwrap();
18327        let receipt = shared
18328            .resolve_environment_profile(&resolved)
18329            .expect("registered via file load")
18330            .expect("env profile present");
18331        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18332    }
18333
18334    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18335    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18336    #[test]
18337    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18338        let (shared, _defaults) = station_knobs_fixture();
18339        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18340        shared
18341            .network_allowlist_catalog
18342            .write()
18343            .unwrap_or_else(|poisoned| poisoned.into_inner())
18344            .insert_id(allowlist.clone())
18345            .unwrap();
18346        let snapshot = shared.snapshot();
18347        let inventory = snapshot
18348            .launch_inventory
18349            .expect("launch inventory present");
18350        let ids = inventory
18351            .network_allowlists
18352            .expect("non-empty catalog surfaces ids");
18353        assert_eq!(ids, vec![allowlist]);
18354    }
18355
18356    /// Track B: registered id with provider_native codex_network_access refuses
18357    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18358    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18359    #[test]
18360    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18361        let (shared, defaults) = station_knobs_fixture();
18362        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18363        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18364            id: allowlist.clone(),
18365            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18366                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18367                peer: "127.0.0.1:443".to_owned(),
18368            }],
18369            provider_native: Some(
18370                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18371                    codex_network_access: Some(true),
18372                },
18373            ),
18374        };
18375        shared
18376            .network_allowlist_catalog
18377            .write()
18378            .unwrap_or_else(|poisoned| poisoned.into_inner())
18379            .insert(entry)
18380            .unwrap();
18381        let spec = crate::protocol::SpawnSpec {
18382            target: crate::protocol::SpawnTarget {
18383                node_id: shared.node_id.clone(),
18384                workspace_id: WorkspaceId::new("primary").unwrap(),
18385                worktree_id: None,
18386            },
18387            profile_id: defaults.profile_id.clone(),
18388            expected_profile_revision: defaults.revision.clone(),
18389            overrides: crate::protocol::SpawnOverrides {
18390                network_allowlist: Some(allowlist),
18391                ..crate::protocol::SpawnOverrides::default()
18392            },
18393            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18394            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18395                "station-knobs-unsupported-mapping",
18396            )
18397            .unwrap(),
18398            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18399        };
18400        let resolved = spec.resolve(&defaults).unwrap();
18401        assert_eq!(resolved.provider.as_str(), "claude");
18402        let failure = shared
18403            .resolve_environment_profile(&resolved)
18404            .expect_err("claude + codex_network_access must refuse");
18405        assert_eq!(
18406            failure.code,
18407            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18408        );
18409    }
18410
18411    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18412    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18413    /// Never secrets on C2.
18414    #[test]
18415    fn resolve_codex_network_access_overlay_moderate_only() {
18416        let (shared, defaults) = station_knobs_fixture();
18417        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18418        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18419            id: allowlist.clone(),
18420            permits: Vec::new(),
18421            provider_native: Some(
18422                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18423                    codex_network_access: Some(true),
18424                },
18425            ),
18426        };
18427        shared
18428            .network_allowlist_catalog
18429            .write()
18430            .unwrap_or_else(|poisoned| poisoned.into_inner())
18431            .insert(entry)
18432            .unwrap();
18433
18434        let overlay = shared
18435            .resolve_codex_network_access_overlay_args(
18436                &agent("codex"),
18437                SessionMode::Pty,
18438                ApprovalLevel::Moderate,
18439                Some(&allowlist),
18440            )
18441            .expect("Moderate Codex maps network_access");
18442        assert_eq!(
18443            overlay,
18444            vec![
18445                std::ffi::OsString::from("-c"),
18446                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18447            ]
18448        );
18449
18450        let acp = shared
18451            .resolve_codex_network_access_overlay_args(
18452                &agent("codex"),
18453                SessionMode::Acp,
18454                ApprovalLevel::Moderate,
18455                Some(&allowlist),
18456            )
18457            .unwrap();
18458        assert_eq!(acp, overlay);
18459
18460        let inline = shared
18461            .resolve_codex_network_access_overlay_args(
18462                &agent("codex"),
18463                SessionMode::Inline,
18464                ApprovalLevel::Moderate,
18465                Some(&allowlist),
18466            )
18467            .unwrap_err();
18468        assert_eq!(
18469            inline.code,
18470            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18471        );
18472
18473        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18474            let spec = crate::protocol::SpawnSpec {
18475                target: crate::protocol::SpawnTarget {
18476                    node_id: shared.node_id.clone(),
18477                    workspace_id: WorkspaceId::new("primary").unwrap(),
18478                    worktree_id: None,
18479                },
18480                profile_id: defaults.profile_id.clone(),
18481                expected_profile_revision: defaults.revision.clone(),
18482                overrides: crate::protocol::SpawnOverrides {
18483                    provider: crate::protocol::SpawnOverride::Set {
18484                        value: agent("codex"),
18485                    },
18486                    approval_level: Some(level),
18487                    network_allowlist: Some(allowlist.clone()),
18488                    ..crate::protocol::SpawnOverrides::default()
18489                },
18490                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18491                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18492                    "codex-net-refuse-{}",
18493                    match level {
18494                        ApprovalLevel::ReadOnly => "ro",
18495                        ApprovalLevel::FullAuto => "fa",
18496                        _ => "other",
18497                    }
18498                ))
18499                .unwrap(),
18500                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18501            };
18502            let resolved = spec.resolve(&defaults).unwrap();
18503            let failure = shared
18504                .resolve_environment_profile(&resolved)
18505                .expect_err("RO/FullAuto must refuse");
18506            assert_eq!(
18507                failure.code,
18508                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18509            );
18510        }
18511    }
18512
18513    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18514    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18515    /// Never secrets on C2; does not invent provider_native Claude keys.
18516    #[test]
18517    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18518        let (shared, _defaults) = station_knobs_fixture();
18519        let allowlist =
18520            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18521        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18522            id: allowlist.clone(),
18523            permits: vec![
18524                crate::network_allowlist_catalog::NetworkPermitSketch {
18525                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18526                    peer: "github.com:443".to_owned(),
18527                },
18528                crate::network_allowlist_catalog::NetworkPermitSketch {
18529                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18530                    peer: "*.npmjs.org".to_owned(),
18531                },
18532            ],
18533            provider_native: None,
18534        };
18535        shared
18536            .network_allowlist_catalog
18537            .write()
18538            .unwrap_or_else(|poisoned| poisoned.into_inner())
18539            .insert(entry)
18540            .unwrap();
18541
18542        let unique = std::time::SystemTime::now()
18543            .duration_since(std::time::UNIX_EPOCH)
18544            .unwrap()
18545            .as_nanos();
18546        let provider_home = std::env::temp_dir().join(format!(
18547            "gate4agent-claude-provider-home-{}-{unique}",
18548            std::process::id(),
18549        ));
18550        std::fs::create_dir_all(&provider_home).unwrap();
18551
18552        let overlay = shared
18553            .resolve_network_allowlist_overlay_args(
18554                &agent("claude"),
18555                SessionMode::Pty,
18556                ApprovalLevel::Moderate,
18557                Some(&allowlist),
18558                Some(provider_home.as_path()),
18559            )
18560            .expect("Claude PTY settings overlay on unix");
18561        let settings_path = provider_home.join(
18562            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18563        );
18564        assert_eq!(
18565            overlay,
18566            vec![
18567                std::ffi::OsString::from("--settings"),
18568                std::ffi::OsString::from(settings_path.as_os_str()),
18569            ]
18570        );
18571        let body = std::fs::read_to_string(&settings_path).unwrap();
18572        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18573        assert_eq!(value["sandbox"]["enabled"], true);
18574        assert_eq!(
18575            value["sandbox"]["network"]["allowedDomains"],
18576            serde_json::json!(["github.com", "*.npmjs.org"])
18577        );
18578        assert!(settings_path.starts_with(&provider_home));
18579
18580        let acp = shared
18581            .resolve_network_allowlist_overlay_args(
18582                &agent("claude"),
18583                SessionMode::Acp,
18584                ApprovalLevel::Moderate,
18585                Some(&allowlist),
18586                Some(provider_home.as_path()),
18587            )
18588            .expect_err("Claude ACP must refuse settings overlay");
18589        assert_eq!(
18590            acp.code,
18591            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18592        );
18593
18594        let no_home = shared
18595            .resolve_network_allowlist_overlay_args(
18596                &agent("claude"),
18597                SessionMode::Pty,
18598                ApprovalLevel::Moderate,
18599                Some(&allowlist),
18600                None,
18601            )
18602            .expect_err("Claude overlay requires ProviderHome");
18603        assert_eq!(
18604            no_home.code,
18605            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18606        );
18607
18608        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18609        shared
18610            .network_allowlist_catalog
18611            .write()
18612            .unwrap_or_else(|poisoned| poisoned.into_inner())
18613            .insert_id(bare.clone())
18614            .unwrap();
18615        let empty = shared
18616            .resolve_network_allowlist_overlay_args(
18617                &agent("claude"),
18618                SessionMode::Pty,
18619                ApprovalLevel::Moderate,
18620                Some(&bare),
18621                Some(provider_home.as_path()),
18622            )
18623            .unwrap();
18624        assert!(empty.is_empty());
18625
18626        let _ = std::fs::remove_dir_all(&provider_home);
18627    }
18628
18629    /// Kimi / Grok with permits stay without invented argv overlays.
18630    #[test]
18631    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18632        let (shared, _defaults) = station_knobs_fixture();
18633        let allowlist =
18634            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18635        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18636            id: allowlist.clone(),
18637            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18638                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18639                peer: "example.com:443".to_owned(),
18640            }],
18641            provider_native: None,
18642        };
18643        shared
18644            .network_allowlist_catalog
18645            .write()
18646            .unwrap_or_else(|poisoned| poisoned.into_inner())
18647            .insert(entry)
18648            .unwrap();
18649        for provider in ["kimi", "grok"] {
18650            let overlay = shared
18651                .resolve_network_allowlist_overlay_args(
18652                    &agent(provider),
18653                    SessionMode::Pty,
18654                    ApprovalLevel::Moderate,
18655                    Some(&allowlist),
18656                    None,
18657                )
18658                .unwrap();
18659            assert!(
18660                overlay.is_empty(),
18661                "{provider} must not invent network argv from permits"
18662            );
18663        }
18664    }
18665
18666    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18667    #[test]
18668    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18669        let unique = std::time::SystemTime::now()
18670            .duration_since(std::time::UNIX_EPOCH)
18671            .unwrap()
18672            .as_nanos();
18673        let path = std::env::temp_dir().join(format!(
18674            "gate4agent-node-allowlist-json-{}-{unique}.json",
18675            std::process::id(),
18676        ));
18677        std::fs::write(
18678            &path,
18679            r#"{
18680              "schema_version": 2,
18681              "entries": [
18682                {
18683                  "id": "egress-default",
18684                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18685                },
18686                { "id": "lab-egress" }
18687              ]
18688            }"#,
18689        )
18690        .unwrap();
18691        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18692            .expect("json v2 catalog must load");
18693        let _ = std::fs::remove_file(&path);
18694        assert_eq!(loaded.len(), 2);
18695        let entry = loaded
18696            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18697            .unwrap();
18698        assert_eq!(entry.permits.len(), 1);
18699        let (shared, defaults) = station_knobs_fixture();
18700        {
18701            let mut catalog = shared
18702                .network_allowlist_catalog
18703                .write()
18704                .unwrap_or_else(|poisoned| poisoned.into_inner());
18705            *catalog = loaded;
18706        }
18707        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18708        let spec = crate::protocol::SpawnSpec {
18709            target: crate::protocol::SpawnTarget {
18710                node_id: shared.node_id.clone(),
18711                workspace_id: WorkspaceId::new("primary").unwrap(),
18712                worktree_id: None,
18713            },
18714            profile_id: defaults.profile_id.clone(),
18715            expected_profile_revision: defaults.revision.clone(),
18716            overrides: crate::protocol::SpawnOverrides {
18717                network_allowlist: Some(allowlist.clone()),
18718                ..crate::protocol::SpawnOverrides::default()
18719            },
18720            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18721            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18722                "station-knobs-catalog-json",
18723            )
18724            .unwrap(),
18725            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18726        };
18727        let resolved = spec.resolve(&defaults).unwrap();
18728        let receipt = shared
18729            .resolve_environment_profile(&resolved)
18730            .expect("permits-only entry ok for claude")
18731            .expect("env profile present");
18732        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18733        let inventory = shared
18734            .snapshot()
18735            .launch_inventory
18736            .expect("launch inventory present");
18737        let ids = inventory.network_allowlists.expect("ids surface");
18738        assert_eq!(
18739            ids,
18740            vec![
18741                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18742                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18743            ]
18744        );
18745    }
18746
18747
18748}
18749
18750
18751#[cfg(test)]
18752mod workspace_environment_collision_tests {
18753    use super::*;
18754    use crate::session_environment::{
18755        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18756    };
18757    use gate4agent_catalog::EnvMutation;
18758    use gate4agent_runtime_native::{
18759        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18760        NativeLaunchProfileId,
18761    };
18762    use std::ffi::OsString;
18763    use std::sync::Arc;
18764
18765    struct EmptyEnvironmentResolver;
18766
18767    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18768        fn resolve_child_environment(
18769            &self,
18770        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18771            Ok(vec![EnvMutation {
18772                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18773                value: None,
18774            }])
18775        }
18776    }
18777
18778    fn agent(value: &str) -> AgentId {
18779        AgentId::new(value).unwrap()
18780    }
18781
18782    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18783    fn claude_config_dir_collision_fixture() -> (
18784        NodeShared,
18785        ResolvedEnvironmentProfileReceipt,
18786        ResolvedEnvironmentProfileReceipt,
18787    ) {
18788        let catalog = active_registry().unwrap();
18789        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18790        let control = runtime.native_launch_profile_control();
18791        let workspace = WorkspaceConfig::new(
18792            WorkspaceId::new("primary").unwrap(),
18793            std::env::current_dir().unwrap(),
18794        )
18795        .unwrap();
18796        let shared = NodeShared::new_with_incarnation(
18797            handle,
18798            "fixture-token".to_owned(),
18799            NodeId::new("node-env-collision-test").unwrap(),
18800            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18801            vec![workspace],
18802            vec![agent("claude")],
18803            ProviderRuntimeStatuses::default(),
18804            None,
18805            Vec::new(),
18806            Vec::new(),
18807            SpawnProfileRegistry::default(),
18808            Some(control),
18809            None,
18810            None,
18811            None,
18812            Vec::new(),
18813            Vec::new(),
18814            Vec::new(),
18815            Vec::new(),
18816            None,
18817            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18818        );
18819
18820        let mut receipts = Vec::new();
18821        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18822            let profile_id =
18823                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18824            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18825                "claude-config-dir-{suffix}-r1"
18826            ))
18827            .unwrap();
18828            let materialization = NodeSessionMaterializationProfile::new(
18829                Vec::new(),
18830                vec![NodeSessionPathBinding::new(
18831                    "CLAUDE_CONFIG_DIR",
18832                    NodeSessionPathClass::ProviderHome,
18833                )
18834                .unwrap()],
18835                Vec::new(),
18836            )
18837            .unwrap();
18838            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18839                profile_id.clone(),
18840                profile_revision.clone(),
18841                agent("claude"),
18842                [NativeLaunchProfile::new(
18843                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18844                        .unwrap(),
18845                    agent("claude"),
18846                    TransportKind::Pty,
18847                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18848                    Arc::new(EmptyEnvironmentResolver),
18849                )
18850                .unwrap()],
18851                Some(materialization),
18852            )
18853            .unwrap();
18854            let (binding, native_profiles, materialization) = node_profile.into_parts();
18855            for native_profile in native_profiles {
18856                runtime.upsert_native_launch_profile(native_profile).unwrap();
18857            }
18858            shared
18859                .environment_profiles
18860                .write()
18861                .unwrap_or_else(|poisoned| poisoned.into_inner())
18862                .insert(binding.id.clone(), binding);
18863            shared
18864                .environment_materialization_profiles
18865                .write()
18866                .unwrap_or_else(|poisoned| poisoned.into_inner())
18867                .insert(profile_id.clone(), materialization.unwrap());
18868            receipts.push(ResolvedEnvironmentProfileReceipt {
18869                profile_id,
18870                profile_revision,
18871                network_allowlist: None,
18872                browser_profile_id: None,
18873            });
18874        }
18875        let profile_b = receipts.pop().unwrap();
18876        let profile_a = receipts.pop().unwrap();
18877        (shared, profile_a, profile_b)
18878    }
18879
18880    #[test]
18881    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18882        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18883        let primary = WorkspaceId::new("primary").unwrap();
18884        let worktree = WorkspaceId::new("worktree-b").unwrap();
18885        let address = SessionAddress {
18886            workspace_id: primary.clone(),
18887            session: SessionKey {
18888                instance_id: AgentInstanceId(41),
18889                generation: SessionGeneration(1),
18890            },
18891        };
18892        shared.bind_session_with_policy(
18893            &address,
18894            ProviderRuntimePolicy::raw_pty(),
18895            Some(profile_a.clone()),
18896        );
18897
18898        let refused = shared
18899            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18900            .unwrap_err();
18901        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18902
18903        // Some vs None on the same cwd is also a collision.
18904        let refused_none = shared
18905            .ensure_workspace_environment_profile_compatible(&primary, None)
18906            .unwrap_err();
18907        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18908
18909        // Same profile on the same workspace remains allowed.
18910        shared
18911            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18912            .unwrap();
18913
18914        // Distinct workspace id (managed-worktree parallel path) allows profile B.
18915        shared
18916            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18917            .unwrap();
18918    }
18919
18920    #[tokio::test]
18921    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18922        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18923        let primary = WorkspaceId::new("primary").unwrap();
18924        let address = SessionAddress {
18925            workspace_id: primary.clone(),
18926            session: SessionKey {
18927                instance_id: AgentInstanceId(41),
18928                generation: SessionGeneration(1),
18929            },
18930        };
18931        shared.bind_session_with_policy(
18932            &address,
18933            ProviderRuntimePolicy::raw_pty(),
18934            Some(profile_a),
18935        );
18936
18937        let failure = shared
18938            .spawn_session_with_deadline(
18939                primary,
18940                agent("claude"),
18941                SessionMode::Pty,
18942                gate4agent_types::TerminalSize {
18943                    rows: 24,
18944                    columns: 80,
18945                },
18946                None,
18947                Some(profile_b),
18948                None,
18949                None,
18950                None,
18951                None,
18952                SpawnRecordPolicy::ProviderIdentityOnly,
18953                Some(Instant::now() + Duration::from_secs(30)),
18954                &[],
18955                None,
18956                ApprovalLevel::default(),
18957                None,
18958                None,
18959            )
18960            .await
18961            .unwrap_err();
18962        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18963        // Guard runs before instance id mint / bind.
18964        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18965        assert!(shared.handle.snapshot().sessions.is_empty());
18966    }
18967}
18968
18969#[cfg(test)]
18970mod spawn_characterization_tests {
18971    use super::*;
18972    use crate::session_environment::{
18973        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18974        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18975        NodeSessionPathClass,
18976    };
18977    use gate4agent_catalog::EnvMutation;
18978    use gate4agent_runtime_native::{
18979        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18980        NativeLaunchProfile, NativeLaunchProfileId,
18981    };
18982    use std::ffi::OsString;
18983    use std::sync::Arc;
18984
18985    struct EmptyEnvironmentResolver;
18986
18987    struct FixtureSecretResolver {
18988        deny: Arc<AtomicBool>,
18989    }
18990
18991    impl NodeSecretResolver for FixtureSecretResolver {
18992        fn resolve(
18993            &self,
18994            _reference: &NodeSecretReference,
18995        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18996            if self.deny.load(Ordering::Acquire) {
18997                Err(NodeSecretResolveError::Denied)
18998            } else {
18999                NodeSecretValue::text("fixture-secret")
19000                    .map_err(|_| NodeSecretResolveError::Unavailable)
19001            }
19002        }
19003    }
19004
19005    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19006        fn resolve_child_environment(
19007            &self,
19008        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19009            Ok(vec![EnvMutation {
19010                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19011                value: None,
19012            }])
19013        }
19014    }
19015
19016    fn agent(value: &str) -> AgentId {
19017        AgentId::new(value).unwrap()
19018    }
19019
19020    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19021        let unique = std::time::SystemTime::now()
19022            .duration_since(std::time::UNIX_EPOCH)
19023            .unwrap()
19024            .as_nanos();
19025        std::env::temp_dir().join(format!(
19026            "gate4agent-node-{label}-{}-{unique}",
19027            std::process::id(),
19028        ))
19029    }
19030
19031    fn materialization_test_shared(
19032        deny_secret: bool,
19033    ) -> (
19034        NodeShared,
19035        NativeRuntime,
19036        ResolvedEnvironmentProfileReceipt,
19037        PathBuf,
19038        Arc<AtomicBool>,
19039    ) {
19040        let workspace = WorkspaceConfig::new(
19041            WorkspaceId::new("primary").unwrap(),
19042            std::env::current_dir().unwrap(),
19043        )
19044        .unwrap();
19045        materialization_test_shared_with_workspace(deny_secret, workspace)
19046    }
19047
19048    fn materialization_test_shared_with_workspace(
19049        deny_secret: bool,
19050        workspace: WorkspaceConfig,
19051    ) -> (
19052        NodeShared,
19053        NativeRuntime,
19054        ResolvedEnvironmentProfileReceipt,
19055        PathBuf,
19056        Arc<AtomicBool>,
19057    ) {
19058        let root = temporary_workspace_root(if deny_secret {
19059            "materialization-denied"
19060        } else {
19061            "materialization-ready"
19062        });
19063        std::fs::create_dir_all(&root).unwrap();
19064        let materialization_root = root.join("session-environments");
19065        let deny = Arc::new(AtomicBool::new(deny_secret));
19066        let resolver = Arc::new(FixtureSecretResolver {
19067            deny: Arc::clone(&deny),
19068        });
19069        let materializer = SessionEnvironmentMaterializer::new(
19070            materialization_root,
19071            resolver,
19072        )
19073        .unwrap();
19074        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19075        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19076            "materialized-claude-r1",
19077        )
19078        .unwrap();
19079        let materialization = NodeSessionMaterializationProfile::new(
19080            vec![NodeSessionEnvironmentMutation::SetSecret {
19081                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19082                reference: NodeSecretReference::new("fixture-token").unwrap(),
19083            }],
19084            vec![NodeSessionPathBinding::new(
19085                "GATE4AGENT_PROVIDER_HOME",
19086                NodeSessionPathClass::ProviderHome,
19087            )
19088            .unwrap()],
19089            vec![NodeSessionFile::secret(
19090                NodeSessionPathClass::Config,
19091                "auth/token",
19092                NodeSecretReference::new("fixture-token").unwrap(),
19093            )
19094            .unwrap()],
19095        )
19096        .unwrap();
19097        let catalog = active_registry().unwrap();
19098        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19099        let control = runtime.native_launch_profile_control();
19100        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19101            profile_id.clone(),
19102            profile_revision.clone(),
19103            agent("claude"),
19104            [NativeLaunchProfile::new(
19105                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19106                agent("claude"),
19107                TransportKind::Pty,
19108                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19109                Arc::new(EmptyEnvironmentResolver),
19110            )
19111            .unwrap()],
19112            Some(materialization),
19113        )
19114        .unwrap();
19115        let (binding, native_profiles, materialization) = node_profile.into_parts();
19116        for native_profile in native_profiles {
19117            runtime.upsert_native_launch_profile(native_profile).unwrap();
19118        }
19119        let shared = NodeShared::new_with_incarnation(
19120            handle,
19121            "fixture-token".to_owned(),
19122            NodeId::new("node-materialization-test").unwrap(),
19123            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19124            vec![workspace],
19125            vec![agent("claude")],
19126            ProviderRuntimeStatuses::default(),
19127            None,
19128            Vec::new(),
19129            Vec::new(),
19130            SpawnProfileRegistry::default(),
19131            Some(control),
19132            None,
19133            Some(materializer),
19134            None,
19135            Vec::new(),
19136            Vec::new(),
19137            Vec::new(),
19138            Vec::new(),
19139            None,
19140            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19141        );
19142        shared
19143            .environment_profiles
19144            .write()
19145            .unwrap_or_else(|poisoned| poisoned.into_inner())
19146            .insert(binding.id.clone(), binding);
19147        shared
19148            .environment_materialization_profiles
19149            .write()
19150            .unwrap_or_else(|poisoned| poisoned.into_inner())
19151            .insert(profile_id.clone(), materialization.unwrap());
19152        (
19153            shared,
19154            runtime,
19155            ResolvedEnvironmentProfileReceipt {
19156                profile_id,
19157                profile_revision,
19158                network_allowlist: None,
19159                browser_profile_id: None,
19160            },
19161            root,
19162            deny,
19163        )
19164    }
19165
19166
19167    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19168
19169    async fn characterization_spawn(
19170        shared: &NodeShared,
19171        mode: SessionMode,
19172        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19173        required: &[ProviderRuntimeCapability],
19174        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19175    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19176        shared
19177            .spawn_session_with_deadline(
19178                WorkspaceId::new("primary").unwrap(),
19179                agent("claude"),
19180                mode,
19181                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19182                None,
19183                environment_profile,
19184                None,
19185                None,
19186                None,
19187                Some(ProviderRuntimePolicy::raw_pty()),
19188                SpawnRecordPolicy::ProviderIdentityOnly,
19189                Some(Instant::now() + Duration::from_secs(30)),
19190                required,
19191                harness_mcp,
19192                ApprovalLevel::default(),
19193                None,
19194                None,
19195            )
19196            .await
19197    }
19198
19199    fn assert_no_session_state(shared: &NodeShared) {
19200        assert!(shared.handle.snapshot().sessions.is_empty());
19201        assert!(shared
19202            .session_bindings
19203            .lock()
19204            .unwrap_or_else(|poisoned| poisoned.into_inner())
19205            .is_empty());
19206        assert!(shared
19207            .materializations
19208            .lock()
19209            .unwrap_or_else(|poisoned| poisoned.into_inner())
19210            .is_empty());
19211    }
19212
19213    #[tokio::test]
19214    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19215        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19216        let error = characterization_spawn(
19217            &shared,
19218            SessionMode::Pty,
19219            None,
19220            &[ProviderRuntimeCapability::SemanticReadiness],
19221            None,
19222        )
19223        .await
19224        .unwrap_err();
19225        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19226        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19227        assert_no_session_state(&shared);
19228        drop(shared);
19229        drop(runtime);
19230        std::fs::remove_dir_all(root).unwrap();
19231    }
19232
19233    #[tokio::test]
19234    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19235        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19236        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19237            .await
19238            .unwrap_err();
19239        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19240        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19241        assert_no_session_state(&shared);
19242        drop(shared);
19243        drop(runtime);
19244        std::fs::remove_dir_all(root).unwrap();
19245    }
19246
19247    #[test]
19248    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19249        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19250        let address = SessionAddress {
19251            workspace_id: WorkspaceId::new("primary").unwrap(),
19252            session: SessionKey {
19253                instance_id: AgentInstanceId(77),
19254                generation: SessionGeneration(1),
19255            },
19256        };
19257        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19258        let removed = shared.remove_binding(&address).expect("binding removed");
19259        assert!(removed.environment_profile.is_some());
19260        assert!(shared.remove_binding(&address).is_none());
19261        assert_no_session_state(&shared);
19262        drop(shared);
19263        drop(runtime);
19264        std::fs::remove_dir_all(root).unwrap();
19265    }
19266
19267    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19268        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19269        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19270        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19271            .expect("review current test executable as fixture helper");
19272        let registry = HarnessMcpProxyRegistry::new(helper);
19273        let spec = crate::protocol::SpawnSpec {
19274            target: crate::protocol::SpawnTarget {
19275                node_id: NodeId::new("node-characterization").unwrap(),
19276                workspace_id: WorkspaceId::new("primary").unwrap(),
19277                worktree_id: None,
19278            },
19279            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19280            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19281            overrides: crate::protocol::SpawnOverrides::default(),
19282            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19283            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19284            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19285        };
19286        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19287        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19288        let launch = HarnessMcpLaunchV1 {
19289            server_name: "fixture-harness".to_owned(),
19290            args: vec!["--session-proxy".to_owned()],
19291            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19292            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19293            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19294            trace: None,
19295            scrub_env: Vec::new(),
19296        };
19297        let expires = unix_time_ms().saturating_add(30_000);
19298        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19299        registry
19300            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19301            .unwrap()
19302    }
19303
19304    #[tokio::test]
19305    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19306        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19307        let prepared = characterization_prepared_harness("claude").await;
19308        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19309            .await
19310            .unwrap_err();
19311        // Inline is stopped by runtime admission before the harness check runs.
19312        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19313        let mismatched = characterization_prepared_harness("codex").await;
19314        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19315            .await
19316            .unwrap_err();
19317        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19318        assert_no_session_state(&shared);
19319        drop(shared);
19320        drop(runtime);
19321        std::fs::remove_dir_all(root).unwrap();
19322    }
19323
19324    #[tokio::test]
19325    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19326        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19327        shared.native_launch_profile_control = None;
19328        let prepared = characterization_prepared_harness("claude").await;
19329        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19330            .await
19331            .unwrap_err();
19332        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19333        assert_no_session_state(&shared);
19334        drop(shared);
19335        drop(runtime);
19336        std::fs::remove_dir_all(root).unwrap();
19337    }
19338}
19339
19340#[cfg(all(test, windows))]
19341mod tests {
19342    use super::*;
19343    use gate4agent_types::ProviderActivity;
19344    use crate::session_environment::{
19345        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19346        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19347        NodeSessionPathClass,
19348    };
19349    use gate4agent_catalog::EnvMutation;
19350    use gate4agent_runtime_native::{
19351        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19352        NativeLaunchProfile, NativeLaunchProfileId,
19353    };
19354    use std::ffi::OsString;
19355    use std::sync::Arc;
19356
19357    struct EmptyEnvironmentResolver;
19358
19359    struct FixtureSecretResolver {
19360        deny: Arc<AtomicBool>,
19361    }
19362
19363    impl NodeSecretResolver for FixtureSecretResolver {
19364        fn resolve(
19365            &self,
19366            _reference: &NodeSecretReference,
19367        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19368            if self.deny.load(Ordering::Acquire) {
19369                Err(NodeSecretResolveError::Denied)
19370            } else {
19371                NodeSecretValue::text("fixture-secret")
19372                    .map_err(|_| NodeSecretResolveError::Unavailable)
19373            }
19374        }
19375    }
19376
19377    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19378        fn resolve_child_environment(
19379            &self,
19380        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19381            Ok(vec![EnvMutation {
19382                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19383                value: None,
19384            }])
19385        }
19386    }
19387
19388    fn agent(value: &str) -> AgentId {
19389        AgentId::new(value).unwrap()
19390    }
19391
19392    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19393        let catalog = active_registry().unwrap();
19394        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19395        let workspace = WorkspaceConfig::new(
19396            WorkspaceId::new("primary").unwrap(),
19397            std::env::current_dir().unwrap(),
19398        )
19399        .unwrap()
19400        .with_worktree_service_mode(mode);
19401        NodeShared::new(
19402            handle,
19403            "fixture-token".to_owned(),
19404            NodeId::new("node-terminal-test").unwrap(),
19405            vec![workspace],
19406            vec![agent("claude")],
19407        )
19408    }
19409
19410    fn terminal_test_shared() -> NodeShared {
19411        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19412    }
19413
19414    #[cfg(feature = "fixture")]
19415    #[test]
19416    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19417        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19418        assert_eq!(probe.latest_code(), None);
19419
19420        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19421        assert_eq!(
19422            probe.latest_code(),
19423            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19424        );
19425
19426        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19427        assert_eq!(
19428            probe.latest_code(),
19429            Some(NodeFailureCode::SpawnDeadlineExceeded),
19430        );
19431
19432        probe.record(None);
19433        assert_eq!(probe.latest_code(), None);
19434    }
19435
19436    #[test]
19437    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19438        let source = temporary_workspace_root("launch-inventory-source");
19439        let allocation = temporary_workspace_root("launch-inventory-allocation");
19440        std::fs::create_dir_all(&source).unwrap();
19441        std::fs::create_dir_all(&allocation).unwrap();
19442        let workspace_id = WorkspaceId::new("primary").unwrap();
19443        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19444            .unwrap()
19445            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19446        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19447            workspace = workspace
19448                .with_managed_worktree_profile(
19449                    ManagedWorktreeProfile::new(
19450                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19451                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19452                        &allocation,
19453                        "gate4agent",
19454                        "HEAD",
19455                        ManagedWorktreeRetention::RemoveWhenReleased,
19456                    )
19457                    .unwrap(),
19458                )
19459                .unwrap();
19460        }
19461        let overflow = workspace.clone().with_managed_worktree_profile(
19462            ManagedWorktreeProfile::new(
19463                WorktreeProfileId::new("overflow").unwrap(),
19464                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19465                &allocation,
19466                "gate4agent",
19467                "HEAD",
19468                ManagedWorktreeRetention::Retain,
19469            )
19470            .unwrap(),
19471        );
19472        assert!(matches!(
19473            overflow,
19474            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19475                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19476                ..
19477            }),
19478        ));
19479
19480        let catalog = active_registry().unwrap();
19481        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19482        let shared = NodeShared::new(
19483            handle,
19484            "fixture-token".to_owned(),
19485            NodeId::new("node-launch-inventory").unwrap(),
19486            vec![workspace],
19487            vec![agent("claude")],
19488        );
19489        let snapshot = shared.snapshot();
19490        let launch = snapshot.launch_inventory.as_ref().unwrap();
19491        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19492        assert!(launch.bundles.as_ref().unwrap().is_empty());
19493        let profiles = snapshot.workspaces[0]
19494            .managed_worktree_profiles
19495            .as_ref()
19496            .unwrap();
19497        assert_eq!(
19498            snapshot.workspaces[0].worktree_service_mode,
19499            Some(WorktreeServiceMode::Managed),
19500        );
19501        assert_eq!(
19502            profiles.profiles.len(),
19503            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19504        );
19505        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19506        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19507            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19508        }
19509        let legacy = snapshot_for_wire(
19510            &shared,
19511            true,
19512            true,
19513            false,
19514            false,
19515            true,
19516            false,
19517            true,
19518            false,
19519            true,
19520        );
19521        assert!(legacy.launch_inventory.is_none());
19522        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19523
19524        drop(shared);
19525        std::fs::remove_dir_all(source).unwrap();
19526        std::fs::remove_dir_all(allocation).unwrap();
19527    }
19528
19529    #[test]
19530    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19531        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19532            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19533        }));
19534        let source = gate4agent_types::ProviderSource {
19535            family: AdapterFamily::PtySemantic,
19536            binding: AdapterBinding::new(
19537                gate4agent_types::AdapterId::new("claude").unwrap(),
19538                "fixture/v1",
19539                gate4agent_types::AdapterVerification::SyntheticFixture,
19540            )
19541            .unwrap(),
19542        };
19543        let provider = ProviderSnapshot {
19544            sequence: 17,
19545            session: Some(ProviderSessionIdentity {
19546                key: ProviderSessionKey::SessionId,
19547                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19548                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19549            }),
19550            model: Some("PRIVATE_MODEL".to_owned()),
19551            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19552            completed_turns: 3,
19553            usage: gate4agent_types::TokenUsage {
19554                input_tokens: 10,
19555                output_tokens: 20,
19556                cache_read_tokens: 30,
19557                cache_write_tokens: 40,
19558                reasoning_tokens: 50,
19559                context_window: Some(200_000),
19560            },
19561            lead_activity: ProviderActivity::WaitingForInput,
19562            activity: ProviderActivity::WaitingForInput,
19563            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19564            active_tools: (0..9)
19565                .map(|index| gate4agent_types::ActiveProviderTool {
19566                    id: format!("private-tool-id-{index}"),
19567                    name: if index == 0 {
19568                        format!("  Read\u{0000}{}", "x".repeat(80))
19569                    } else {
19570                        format!("Tool-{index}")
19571                    },
19572                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19573                })
19574                .collect(),
19575            interactions: vec![gate4agent_types::ProviderInteraction {
19576                id: gate4agent_types::ProviderInteractionId(17),
19577                source: source.clone(),
19578                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19579                interaction_kind: ProviderInteractionKind::Approval,
19580                tool_name: " Approve\u{0007}Tool ".to_owned(),
19581                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19582                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19583                resume_lead_activity: Some(ProviderActivity::Working),
19584                status: ProviderInteractionStatus::Pending,
19585            }],
19586            subagents: vec![gate4agent_types::ProviderSubagent {
19587                source: source.clone(),
19588                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19589                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19590                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19591            }],
19592            sources: vec![gate4agent_types::ProviderSourceCursor {
19593                source,
19594                sequence: 17,
19595                gap_count: 2,
19596                stale: true,
19597            }],
19598            last_event: Some(ProviderEvent::Error {
19599                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19600            }),
19601            gap_count: 2,
19602            stale: true,
19603        };
19604        let address = terminal_address(3);
19605        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19606        assert_eq!(entry.address, address);
19607        assert_eq!(entry.progress.provider_sequence, 17);
19608        assert_eq!(entry.progress.active_tool_count, 9);
19609        assert_eq!(
19610            entry.progress.active_tool_labels.len(),
19611            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19612        );
19613        assert_eq!(entry.progress.subagent_count, 1);
19614        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19615        assert!(entry.progress.truncated);
19616        let encoded = serde_json::to_string(&entry).unwrap();
19617        for forbidden in [
19618            "PRIVATE_PROVIDER_SESSION",
19619            "PRIVATE_PROVIDER_PATH",
19620            "PRIVATE_MODEL",
19621            "PRIVATE_TOOL_INVENTORY",
19622            "PRIVATE_CURRENT_PROMPT",
19623            "PRIVATE_TOOL_INPUT_JSON",
19624            "PRIVATE_PROVIDER_REQUEST",
19625            "PRIVATE_INTERACTION_PROMPT",
19626            "PRIVATE_INTERACTION_AGENT",
19627            "PRIVATE_SUBAGENT_ID",
19628            "PRIVATE_SUBAGENT_TYPE",
19629            "PRIVATE_SUBAGENT_DESCRIPTION",
19630            "PRIVATE_PROVIDER_ERROR",
19631            "input_json",
19632            "current_prompt",
19633            "session_id",
19634        ] {
19635            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19636        }
19637
19638        let snapshot = NodeSnapshot {
19639            node_id: NodeId::new("node-progress-test").unwrap(),
19640            enabled_providers: vec![agent("claude")],
19641            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19642            workspaces: Vec::new(),
19643            session_records: Vec::new(),
19644            managed_worktrees: Vec::new(),
19645            launch_inventory: None,
19646            agent_progress: vec![entry],
19647        };
19648        let mut legacy_reply = ResponseEnvelope {
19649            request_id: 1,
19650            result: Ok(NodeResponse::Snapshot {
19651                event_sequence: 0,
19652                controller: None,
19653                snapshot: snapshot.clone(),
19654            }),
19655        };
19656        project_response_without_agent_progress(&mut legacy_reply);
19657        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19658            panic!("agent progress projection changed response kind");
19659        };
19660        assert!(legacy.agent_progress.is_empty());
19661        assert_eq!(snapshot.agent_progress.len(), 1);
19662    }
19663
19664    fn terminal_address(generation: u64) -> SessionAddress {
19665        SessionAddress {
19666            workspace_id: WorkspaceId::new("primary").unwrap(),
19667            session: SessionKey {
19668                instance_id: AgentInstanceId(41),
19669                generation: SessionGeneration(generation),
19670            },
19671        }
19672    }
19673
19674    fn spawn_spec_fixture() -> SpawnSpec {
19675        SpawnSpec {
19676            target: crate::protocol::SpawnTarget {
19677                node_id: NodeId::new("node-terminal-test").unwrap(),
19678                workspace_id: WorkspaceId::new("primary").unwrap(),
19679                worktree_id: None,
19680            },
19681            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19682            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19683                .unwrap(),
19684            overrides: crate::protocol::SpawnOverrides::default(),
19685            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19686            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19687            required_capabilities: SpawnRequiredCapabilities::default(),
19688        }
19689    }
19690
19691    fn environment_profile_test_shared(
19692    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19693        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19694        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19695            "local-claude-r1",
19696        )
19697        .unwrap();
19698        let spawn_profiles = SpawnProfileRegistry::new([
19699            crate::protocol::SpawnProfileDefaults {
19700                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19701                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19702                provider: agent("claude"),
19703                mode: SessionMode::Pty,
19704                terminal_size: gate4agent_types::TerminalSize {
19705                    rows: 24,
19706                    columns: 80,
19707                },
19708                prompt: None,
19709                bundle_id: None,
19710                context_id: None,
19711                environment_profile_id: Some(profile_id.clone()),
19712            },
19713        ])
19714        .unwrap();
19715        let catalog = active_registry().unwrap();
19716        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19717        let control = runtime.native_launch_profile_control();
19718        let node_profile = NodeEnvironmentProfile::new(
19719            profile_id.clone(),
19720            profile_revision.clone(),
19721            agent("claude"),
19722            [NativeLaunchProfile::new(
19723                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19724                agent("claude"),
19725                TransportKind::Pty,
19726                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19727                Arc::new(EmptyEnvironmentResolver),
19728            )
19729            .unwrap()],
19730        )
19731        .unwrap();
19732        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19733        for native_profile in native_profiles {
19734            runtime.upsert_native_launch_profile(native_profile).unwrap();
19735        }
19736        let workspace = WorkspaceConfig::new(
19737            WorkspaceId::new("primary").unwrap(),
19738            std::env::current_dir().unwrap(),
19739        )
19740        .unwrap();
19741        let shared = NodeShared::new_with_incarnation(
19742            handle,
19743            "fixture-token".to_owned(),
19744            NodeId::new("node-terminal-test").unwrap(),
19745            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19746            vec![workspace],
19747            vec![agent("claude")],
19748            ProviderRuntimeStatuses::default(),
19749            None,
19750            Vec::new(),
19751            Vec::new(),
19752            spawn_profiles,
19753            Some(control),
19754            None,
19755            None,
19756            None,
19757            Vec::new(),
19758            Vec::new(),
19759            Vec::new(),
19760            Vec::new(),
19761            None,
19762            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19763        );
19764        shared
19765            .environment_profiles
19766            .write()
19767            .unwrap_or_else(|poisoned| poisoned.into_inner())
19768            .insert(binding.id.clone(), binding);
19769        (
19770            shared,
19771            runtime,
19772            ResolvedEnvironmentProfileReceipt {
19773                profile_id,
19774                profile_revision,
19775                network_allowlist: None,
19776                browser_profile_id: None,
19777            },
19778        )
19779    }
19780
19781    fn materialization_test_shared(
19782        deny_secret: bool,
19783    ) -> (
19784        NodeShared,
19785        NativeRuntime,
19786        ResolvedEnvironmentProfileReceipt,
19787        PathBuf,
19788        Arc<AtomicBool>,
19789    ) {
19790        let workspace = WorkspaceConfig::new(
19791            WorkspaceId::new("primary").unwrap(),
19792            std::env::current_dir().unwrap(),
19793        )
19794        .unwrap();
19795        materialization_test_shared_with_workspace(deny_secret, workspace)
19796    }
19797
19798    fn materialization_test_shared_with_workspace(
19799        deny_secret: bool,
19800        workspace: WorkspaceConfig,
19801    ) -> (
19802        NodeShared,
19803        NativeRuntime,
19804        ResolvedEnvironmentProfileReceipt,
19805        PathBuf,
19806        Arc<AtomicBool>,
19807    ) {
19808        let root = temporary_workspace_root(if deny_secret {
19809            "materialization-denied"
19810        } else {
19811            "materialization-ready"
19812        });
19813        std::fs::create_dir_all(&root).unwrap();
19814        let materialization_root = root.join("session-environments");
19815        let deny = Arc::new(AtomicBool::new(deny_secret));
19816        let resolver = Arc::new(FixtureSecretResolver {
19817            deny: Arc::clone(&deny),
19818        });
19819        let materializer = SessionEnvironmentMaterializer::new(
19820            materialization_root,
19821            resolver,
19822        )
19823        .unwrap();
19824        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19825        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19826            "materialized-claude-r1",
19827        )
19828        .unwrap();
19829        let materialization = NodeSessionMaterializationProfile::new(
19830            vec![NodeSessionEnvironmentMutation::SetSecret {
19831                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19832                reference: NodeSecretReference::new("fixture-token").unwrap(),
19833            }],
19834            vec![NodeSessionPathBinding::new(
19835                "GATE4AGENT_PROVIDER_HOME",
19836                NodeSessionPathClass::ProviderHome,
19837            )
19838            .unwrap()],
19839            vec![NodeSessionFile::secret(
19840                NodeSessionPathClass::Config,
19841                "auth/token",
19842                NodeSecretReference::new("fixture-token").unwrap(),
19843            )
19844            .unwrap()],
19845        )
19846        .unwrap();
19847        let catalog = active_registry().unwrap();
19848        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19849        let control = runtime.native_launch_profile_control();
19850        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19851            profile_id.clone(),
19852            profile_revision.clone(),
19853            agent("claude"),
19854            [NativeLaunchProfile::new(
19855                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19856                agent("claude"),
19857                TransportKind::Pty,
19858                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19859                Arc::new(EmptyEnvironmentResolver),
19860            )
19861            .unwrap()],
19862            Some(materialization),
19863        )
19864        .unwrap();
19865        let (binding, native_profiles, materialization) = node_profile.into_parts();
19866        for native_profile in native_profiles {
19867            runtime.upsert_native_launch_profile(native_profile).unwrap();
19868        }
19869        let shared = NodeShared::new_with_incarnation(
19870            handle,
19871            "fixture-token".to_owned(),
19872            NodeId::new("node-materialization-test").unwrap(),
19873            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19874            vec![workspace],
19875            vec![agent("claude")],
19876            ProviderRuntimeStatuses::default(),
19877            None,
19878            Vec::new(),
19879            Vec::new(),
19880            SpawnProfileRegistry::default(),
19881            Some(control),
19882            None,
19883            Some(materializer),
19884            None,
19885            Vec::new(),
19886            Vec::new(),
19887            Vec::new(),
19888            Vec::new(),
19889            None,
19890            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19891        );
19892        shared
19893            .environment_profiles
19894            .write()
19895            .unwrap_or_else(|poisoned| poisoned.into_inner())
19896            .insert(binding.id.clone(), binding);
19897        shared
19898            .environment_materialization_profiles
19899            .write()
19900            .unwrap_or_else(|poisoned| poisoned.into_inner())
19901            .insert(profile_id.clone(), materialization.unwrap());
19902        (
19903            shared,
19904            runtime,
19905            ResolvedEnvironmentProfileReceipt {
19906                profile_id,
19907                profile_revision,
19908                network_allowlist: None,
19909                browser_profile_id: None,
19910            },
19911            root,
19912            deny,
19913        )
19914    }
19915
19916    #[cfg(feature = "fixture")]
19917    #[derive(Clone, Copy)]
19918    enum CodexHomeProfile {
19919        Missing,
19920        WrongClass,
19921        Exact,
19922    }
19923
19924    #[cfg(feature = "fixture")]
19925    fn codex_bundle_test_shared(
19926        home_profile: CodexHomeProfile,
19927    ) -> (
19928        NodeShared,
19929        NativeRuntime,
19930        ResolvedEnvironmentProfileReceipt,
19931        NodeBundle,
19932        PathBuf,
19933    ) {
19934        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19935        const CLAUDE_MANIFEST: &[u8] =
19936            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19937        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19938        const DIGEST: &str =
19939            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19940
19941        let (shared, mut runtime, _claude_receipt, root, _deny) =
19942            materialization_test_shared(false);
19943        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19944            CodexHomeProfile::Missing => "codex-home-missing",
19945            CodexHomeProfile::WrongClass => "codex-home-wrong",
19946            CodexHomeProfile::Exact => "codex-home-exact",
19947        })
19948        .unwrap();
19949        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19950            "codex-home-r1",
19951        )
19952        .unwrap();
19953        let materialization = match home_profile {
19954            CodexHomeProfile::Missing => None,
19955            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19956                Some(NodeSessionMaterializationProfile::new(
19957                    Vec::new(),
19958                    vec![NodeSessionPathBinding::new(
19959                        "CODEX_HOME",
19960                        if matches!(home_profile, CodexHomeProfile::Exact) {
19961                            NodeSessionPathClass::ProviderHome
19962                        } else {
19963                            NodeSessionPathClass::Config
19964                        },
19965                    )
19966                    .unwrap()],
19967                    Vec::new(),
19968                )
19969                .unwrap())
19970            }
19971        };
19972        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19973            profile_id.clone(),
19974            profile_revision.clone(),
19975            agent("codex"),
19976            [NativeLaunchProfile::new(
19977                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19978                agent("codex"),
19979                TransportKind::Pty,
19980                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19981                Arc::new(EmptyEnvironmentResolver),
19982            )
19983            .unwrap()],
19984            materialization,
19985        )
19986        .unwrap();
19987        let (binding, native_profiles, materialization) = node_profile.into_parts();
19988        for native_profile in native_profiles {
19989            runtime.upsert_native_launch_profile(native_profile).unwrap();
19990        }
19991        shared
19992            .environment_profiles
19993            .write()
19994            .unwrap_or_else(|poisoned| poisoned.into_inner())
19995            .insert(binding.id.clone(), binding);
19996        if let Some(materialization) = materialization {
19997            shared
19998                .environment_materialization_profiles
19999                .write()
20000                .unwrap_or_else(|poisoned| poisoned.into_inner())
20001                .insert(profile_id.clone(), materialization);
20002        }
20003
20004        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20005        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20006        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20007        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20008        std::fs::write(
20009            bundle_source.join(".claude-plugin/plugin.json"),
20010            CLAUDE_MANIFEST,
20011        )
20012        .unwrap();
20013        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20014        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20015        let bundle = NodeBundle::new(
20016            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20017            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20018            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20019            &bundle_source,
20020        )
20021        .unwrap();
20022        *shared
20023            .bundle_catalog
20024            .write()
20025            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20026            BundleCatalog::new([bundle.clone()]).unwrap();
20027        (
20028            shared,
20029            runtime,
20030            ResolvedEnvironmentProfileReceipt {
20031                profile_id,
20032                profile_revision,
20033                network_allowlist: None,
20034                browser_profile_id: None,
20035            },
20036            bundle,
20037            root,
20038        )
20039    }
20040
20041    #[cfg(feature = "fixture")]
20042    #[test]
20043    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20044        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20045            let (shared, runtime, receipt, bundle, root) =
20046                codex_bundle_test_shared(home_profile);
20047            let error = shared
20048                .bundle_layout(
20049                    &agent("codex"),
20050                    SessionMode::Pty,
20051                    Some(&receipt),
20052                    &bundle,
20053                )
20054                .unwrap_err();
20055            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20056            assert!(shared
20057                .materializations
20058                .lock()
20059                .unwrap_or_else(|poisoned| poisoned.into_inner())
20060                .is_empty());
20061            let materialization_root = root.join("session-environments");
20062            assert!(!std::fs::read_dir(&materialization_root)
20063                .unwrap()
20064                .filter_map(Result::ok)
20065                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20066            drop(shared);
20067            drop(runtime);
20068            let _ = std::fs::remove_dir_all(root);
20069        }
20070    }
20071
20072    #[cfg(feature = "fixture")]
20073    #[test]
20074    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20075        let (shared, runtime, receipt, bundle, root) =
20076            codex_bundle_test_shared(CodexHomeProfile::Exact);
20077        let address = SessionAddress {
20078            workspace_id: WorkspaceId::new("primary").unwrap(),
20079            session: SessionKey {
20080                instance_id: AgentInstanceId(310),
20081                generation: SessionGeneration(1),
20082            },
20083        };
20084        let (overlay, guard) = shared
20085            .prepare_session_materialization(
20086                &address,
20087                &agent("codex"),
20088                SessionMode::Pty,
20089                Some(&receipt),
20090                Some(&bundle.receipt()),
20091                None,
20092                None,
20093            )
20094            .unwrap()
20095            .unwrap();
20096        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20097        let ownership = shared
20098            .materializations
20099            .lock()
20100            .unwrap_or_else(|poisoned| poisoned.into_inner())
20101            .get(guard.id().unwrap())
20102            .unwrap()
20103            .clone();
20104        let (profile, _, _) = shared
20105            .materialization_profile(
20106                &agent("codex"),
20107                SessionMode::Pty,
20108                Some(&receipt),
20109                Some(&bundle.receipt()),
20110                None,
20111            )
20112            .unwrap()
20113            .unwrap();
20114        let environment = shared
20115            .session_environment_materializer
20116            .as_ref()
20117            .unwrap()
20118            .resolve_environment(&ownership, &profile)
20119            .unwrap();
20120        assert!(environment.iter().any(|mutation| {
20121            mutation.key == OsString::from("CODEX_HOME")
20122                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20123        }));
20124        assert_eq!(
20125            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20126                .unwrap(),
20127            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20128        );
20129        assert!(std::fs::read_dir(ownership.bundle_root())
20130            .unwrap()
20131            .next()
20132            .is_none());
20133        drop(guard);
20134        drop(shared);
20135        drop(runtime);
20136        let _ = std::fs::remove_dir_all(root);
20137    }
20138
20139    #[cfg(feature = "fixture")]
20140    #[test]
20141    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20142        let (shared, runtime, receipt, bundle, root) =
20143            codex_bundle_test_shared(CodexHomeProfile::Exact);
20144        let bundle_receipt = bundle.receipt();
20145        let address = SessionAddress {
20146            workspace_id: WorkspaceId::new("primary").unwrap(),
20147            session: SessionKey {
20148                instance_id: AgentInstanceId(311),
20149                generation: SessionGeneration(1),
20150            },
20151        };
20152        let (_overlay, guard) = shared
20153            .prepare_session_materialization(
20154                &address,
20155                &agent("codex"),
20156                SessionMode::Pty,
20157                Some(&receipt),
20158                Some(&bundle_receipt),
20159                None,
20160                None,
20161            )
20162            .unwrap()
20163            .unwrap();
20164        let materialization_id = guard.id().unwrap().clone();
20165        let skill_path = shared
20166            .materializations
20167            .lock()
20168            .unwrap_or_else(|poisoned| poisoned.into_inner())
20169            .get(&materialization_id)
20170            .unwrap()
20171            .provider_home()
20172            .join("skills/review-code/SKILL.md");
20173        let record_id = shared
20174            .bind_spawn_session_with_materialization(
20175                &address,
20176                agent("codex"),
20177                SessionMode::Pty,
20178                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20179                SpawnRecordPolicy::ProviderIdentityOnly,
20180                Some(receipt.clone()),
20181                Some(bundle_receipt.clone()),
20182                None,
20183                Some(materialization_id.clone()),
20184            )
20185            .unwrap()
20186            .unwrap();
20187        guard.retain();
20188        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20189
20190        let error = match shared.resolve_record_materialization(
20191                &record_id,
20192                &agent("codex"),
20193                SessionMode::Pty,
20194                Some(&receipt),
20195                Some(&bundle_receipt),
20196                None,
20197            ) {
20198                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20199                Err(error) => error,
20200            };
20201        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20202        assert_eq!(
20203            shared
20204                .materializations
20205                .lock()
20206                .unwrap_or_else(|poisoned| poisoned.into_inner())
20207                .get(&materialization_id)
20208                .unwrap()
20209                .state(),
20210            MaterializationState::RecoveryRequired,
20211        );
20212        drop(shared);
20213        drop(runtime);
20214        let _ = std::fs::remove_dir_all(root);
20215    }
20216
20217    fn bind_record_owned_materialization_fixture(
20218        shared: &NodeShared,
20219        receipt: &ResolvedEnvironmentProfileReceipt,
20220        instance_id: u64,
20221    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20222        let address = SessionAddress {
20223            workspace_id: WorkspaceId::new("primary").unwrap(),
20224            session: SessionKey {
20225                instance_id: AgentInstanceId(instance_id),
20226                generation: SessionGeneration(1),
20227            },
20228        };
20229        let (overlay, materialization_guard) = shared
20230            .prepare_session_materialization(
20231                &address,
20232                &agent("claude"),
20233                SessionMode::Pty,
20234                Some(receipt),
20235                None,
20236                None,
20237                None,
20238            )
20239            .unwrap()
20240            .unwrap();
20241        let materialization_id = materialization_guard.id().unwrap().clone();
20242        let materialization_root = shared
20243            .materializations
20244            .lock()
20245            .unwrap_or_else(|poisoned| poisoned.into_inner())
20246            .get(&materialization_id)
20247            .unwrap()
20248            .root()
20249            .to_path_buf();
20250        let selection = shared
20251            .select_environment_profile(
20252                address.session.instance_id,
20253                &agent("claude"),
20254                SessionMode::Pty,
20255                Some(receipt),
20256            )
20257            .unwrap()
20258            .unwrap();
20259        assert!(shared
20260            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20261            .unwrap()
20262            .is_none());
20263        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20264        let record_id = shared
20265            .bind_spawn_session_with_materialization(
20266                &address,
20267                agent("claude"),
20268                SessionMode::Pty,
20269                policy,
20270                SpawnRecordPolicy::ProviderIdentityOnly,
20271                Some(receipt.clone()),
20272                None,
20273                None,
20274                Some(materialization_id.clone()),
20275            )
20276            .unwrap()
20277            .unwrap();
20278        selection.retain();
20279        materialization_guard.retain();
20280        (address, record_id, materialization_id, materialization_root)
20281    }
20282
20283    #[test]
20284    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20285        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20286        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20287            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20288        }));
20289        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20290        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20291        let mut spec = spawn_spec_fixture();
20292        spec.expected_profile_revision =
20293            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20294        assert!(matches!(
20295            &spec.overrides.environment_profile_id,
20296            crate::protocol::SpawnOverride::Inherit,
20297        ));
20298        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20299            .requires_child_environment_profile_capability());
20300        assert!(request_requires_child_environment_profile(
20301            &shared,
20302            &NodeRequest::SpawnSpec { spec: spec.clone() },
20303        ));
20304
20305        let address = terminal_address(1);
20306        shared.bind_session_with_policy(
20307            &address,
20308            ProviderRuntimePolicy::raw_pty(),
20309            Some(environment_profile.clone()),
20310        );
20311        assert!(request_requires_child_environment_profile(
20312            &shared,
20313            &NodeRequest::Input {
20314                session: address.clone(),
20315                text: "x".to_owned(),
20316            },
20317        ));
20318
20319        let mut record = record("claude", "profile-record");
20320        record.active_session = Some(address.clone());
20321        record.environment_profile = Some(environment_profile.clone());
20322        let record_id = record.record_id.clone();
20323        shared.insert_record(record.clone()).unwrap();
20324        assert!(request_requires_child_environment_profile(
20325            &shared,
20326            &NodeRequest::ResumeSessionRecord {
20327                record_id,
20328                terminal_size: gate4agent_types::TerminalSize {
20329                    rows: 24,
20330                    columns: 80,
20331                },
20332                initial_prompt: None,
20333            },
20334        ));
20335
20336        let snapshot = snapshot_for_wire(
20337            &shared, false, true, true, true, false, false, false, false, true,
20338        );
20339        assert_eq!(snapshot.session_records.len(), 1);
20340        assert!(snapshot.session_records[0].environment_profile.is_none());
20341        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20342            sequence: 1,
20343            event: NodeEvent::SessionRecordUpserted { record },
20344        });
20345        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20346            panic!("record projection changed the event kind");
20347        };
20348        assert!(record.environment_profile.is_none());
20349
20350        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20351        let mut reply = ResponseEnvelope {
20352            request_id: 1,
20353            result: Ok(NodeResponse::SpawnSpecAccepted {
20354                receipt: resolved.receipt_with_environment(
20355                    shared.incarnation_id,
20356                    address,
20357                    Some(environment_profile),
20358                ),
20359            }),
20360        };
20361        project_response_without_child_environment_profile(&mut reply);
20362        assert_eq!(
20363            reply.result.unwrap_err().code,
20364            NodeFailureCode::UnsupportedCapability,
20365        );
20366    }
20367
20368    #[test]
20369    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20370        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20371        let snapshot = shared.snapshot();
20372        let profiles = snapshot
20373            .launch_inventory
20374            .as_ref()
20375            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20376            .unwrap();
20377        assert_eq!(profiles.len(), 1);
20378        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20379        assert!(snapshot.requires_child_environment_profile_capability());
20380
20381        let mut legacy = snapshot.clone();
20382        clear_snapshot_child_environment_profiles(&mut legacy);
20383        assert!(legacy
20384            .launch_inventory
20385            .as_ref()
20386            .unwrap()
20387            .spawn_profiles
20388            .as_ref()
20389            .unwrap()[0]
20390            .environment_profile
20391            .is_none());
20392        assert!(!legacy.requires_child_environment_profile_capability());
20393
20394        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20395        let bindings = shared
20396            .environment_profiles
20397            .read()
20398            .unwrap_or_else(|poisoned| poisoned.into_inner())
20399            .clone();
20400        let mut provider_mismatch = bindings.clone();
20401        provider_mismatch
20402            .values_mut()
20403            .next()
20404            .unwrap()
20405            .provider = agent("codex");
20406        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20407        let mut mode_mismatch = profile;
20408        mode_mismatch.mode = SessionMode::Inline;
20409        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20410
20411        shared
20412            .environment_profiles
20413            .write()
20414            .unwrap_or_else(|poisoned| poisoned.into_inner())
20415            .clear();
20416        assert!(shared
20417            .snapshot()
20418            .launch_inventory
20419            .unwrap()
20420            .spawn_profiles
20421            .unwrap()
20422            .is_empty());
20423    }
20424
20425    #[test]
20426    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20427        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20428            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20429        }));
20430        let shared = terminal_test_shared();
20431        let bundle = ResolvedBundleReceipt {
20432            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20433            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20434            digest: crate::protocol::SpawnBundleDigest::new(format!(
20435                "sha256:{}",
20436                "0".repeat(64),
20437            ))
20438            .unwrap(),
20439        };
20440        let mut bundled = record("claude", "bundle-record");
20441        bundled.bundle = Some(bundle.clone());
20442        let record_id = bundled.record_id.clone();
20443        shared.insert_record(bundled.clone()).unwrap();
20444        assert!(request_requires_session_bundle(
20445            &shared,
20446            &NodeRequest::ForgetSessionRecord {
20447                record_id: record_id.clone(),
20448            },
20449        ));
20450
20451        let snapshot = snapshot_for_wire(
20452            &shared, false, true, true, true, true, false, false, false, true,
20453        );
20454        assert!(snapshot.session_records[0].bundle.is_none());
20455        let launch = snapshot.launch_inventory.as_ref().unwrap();
20456        assert!(launch.spawn_profiles.is_some());
20457        assert!(launch.bundles.is_none());
20458        let event = project_event_without_session_bundle(NodeEventEnvelope {
20459            sequence: 1,
20460            event: NodeEvent::SessionRecordUpserted { record: bundled },
20461        });
20462        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20463            panic!("record projection changed the event kind");
20464        };
20465        assert!(record.bundle.is_none());
20466
20467        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20468        let mut reply = ResponseEnvelope {
20469            request_id: 1,
20470            result: Ok(NodeResponse::SpawnSpecAccepted {
20471                receipt: resolved.receipt_with_materialization(
20472                    shared.incarnation_id,
20473                    terminal_address(17),
20474                    None,
20475                    Some(bundle),
20476                    None,
20477                ),
20478            }),
20479        };
20480        project_response_without_session_bundle(&mut reply);
20481        assert_eq!(
20482            reply.result.unwrap_err().code,
20483            NodeFailureCode::UnsupportedCapability,
20484        );
20485    }
20486
20487    #[test]
20488    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20489        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20490            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20491        }));
20492        let context_id = SpawnContextId::new("context-review").unwrap();
20493        let context = ResolvedContextPackReceipt {
20494            id: context_id.clone(),
20495            digest: crate::protocol::SpawnContextDigest::new(format!(
20496                "sha256:{}",
20497                "c".repeat(64),
20498            ))
20499            .unwrap(),
20500            lineage: ContextPackLineageReceipt {
20501                source_node_id: NodeId::new("node-source").unwrap(),
20502                source_session: terminal_address(1),
20503                source_provider: agent("kimi"),
20504            },
20505            source_message_count: 2,
20506            retained_message_count: 2,
20507            byte_len: 256,
20508            truncated: false,
20509        };
20510        assert!(context.is_valid());
20511        let mut bound_record = record("codex", "context-record");
20512        bound_record.context_id = Some(context_id);
20513        bound_record.context = Some(context.clone());
20514        let session = gate4agent_types::SessionSnapshot {
20515            instance_id: AgentInstanceId(91),
20516            agent_id: agent("kimi"),
20517            transport: TransportKind::Pty,
20518            generation: SessionGeneration(3),
20519            status: SessionStatus::Running,
20520            pending_operation: None,
20521            pending_input: None,
20522            process_id: Some(9001),
20523            terminal_size: None,
20524            terminal_frame: None,
20525            terminal_stale: None,
20526            session_options: None,
20527            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20528            history: gate4agent_types::HistorySnapshot {
20529                pending: None,
20530                candidates: vec![HistoryCandidateSummary {
20531                    id: "candidate-opaque".to_owned(),
20532                    session_id_hint: "session-hint".to_owned(),
20533                    modified_at_unix_ms: Some(1),
20534                }],
20535                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20536                loaded: Some(HistorySessionRecord {
20537                    session_id: "private-vendor-session".to_owned(),
20538                    title: Some("private title".to_owned()),
20539                    cwd: Some(r"C:\private\history-root".to_owned()),
20540                    model: Some("private-model".to_owned()),
20541                    message_count: 2,
20542                    completed_turn_count: None,
20543                    total_tokens: 19,
20544                    messages: vec![
20545                        gate4agent_types::HistoryMessageRecord {
20546                            role: gate4agent_types::HistoryMessageRole::User,
20547                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20548                        },
20549                        gate4agent_types::HistoryMessageRecord {
20550                            role: gate4agent_types::HistoryMessageRole::Assistant,
20551                            text: "private answer".to_owned(),
20552                        },
20553                    ],
20554                }),
20555                last_error: None,
20556            },
20557            resume: gate4agent_types::ResumeSnapshot::default(),
20558            foreground: gate4agent_types::ForegroundSnapshot::default(),
20559            provider: gate4agent_types::ProviderSnapshot::default(),
20560            screen_state: gate4agent_types::PtyScreenState::default(),
20561        };
20562        let original = NodeSnapshot {
20563            node_id: NodeId::new("node-terminal-test").unwrap(),
20564            enabled_providers: vec![agent("kimi"), agent("codex")],
20565            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20566            workspaces: vec![WorkspaceSnapshot {
20567                workspace_id: WorkspaceId::new("primary").unwrap(),
20568                canonical_root: opaque_windows_path(
20569                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20570                ),
20571                sessions: vec![session],
20572                worktree_service_mode: None,
20573                managed_worktree_profiles: None,
20574            }],
20575            session_records: vec![bound_record],
20576            managed_worktrees: Vec::new(),
20577            launch_inventory: None,
20578            agent_progress: Vec::new(),
20579        };
20580
20581        let mut negotiated = original.clone();
20582        project_snapshot_history_for_wire(&mut negotiated, true);
20583        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20584        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20585        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20586        assert!(!serde_json::to_string(&negotiated)
20587            .unwrap()
20588            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20589
20590        let mut legacy = original;
20591        project_snapshot_history_for_wire(&mut legacy, false);
20592        clear_snapshot_context_packs(&mut legacy);
20593        assert_eq!(
20594            legacy.workspaces[0].sessions[0].history,
20595            gate4agent_types::HistorySnapshot::default(),
20596        );
20597        assert!(legacy.session_records[0].context_id.is_none());
20598        assert!(legacy.session_records[0].context.is_none());
20599        let encoded = serde_json::to_string(&legacy).unwrap();
20600        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20601        assert!(!encoded.contains("context-review"));
20602
20603        let mut reply = ResponseEnvelope {
20604            request_id: 7,
20605            result: Ok(NodeResponse::ContextPackExported { context }),
20606        };
20607        project_response_without_context_pack(&mut reply);
20608        assert_eq!(
20609            reply.result.unwrap_err().code,
20610            NodeFailureCode::UnsupportedCapability,
20611        );
20612    }
20613
20614    #[test]
20615    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20616        let shared = terminal_test_shared();
20617        let history = HistorySessionRecord {
20618            session_id: "fixture-history".to_owned(),
20619            title: None,
20620            cwd: None,
20621            model: None,
20622            message_count: 1,
20623            completed_turn_count: None,
20624            total_tokens: 0,
20625            messages: vec![gate4agent_types::HistoryMessageRecord {
20626                role: gate4agent_types::HistoryMessageRole::User,
20627                text: "bounded handoff".to_owned(),
20628            }],
20629        };
20630        let pack = NodeContextPack::export(
20631            ContextPackLineageReceipt {
20632                source_node_id: shared.node_id.clone(),
20633                source_session: terminal_address(1),
20634                source_provider: agent("claude"),
20635            },
20636            &history,
20637        )
20638        .unwrap();
20639        let context = shared
20640            .context_catalog
20641            .write()
20642            .unwrap_or_else(|poisoned| poisoned.into_inner())
20643            .insert(pack)
20644            .unwrap();
20645        let mut retained = record("codex", "context-retained-record");
20646        retained.context_id = Some(context.id.clone());
20647        retained.context = Some(context.clone());
20648        let record_id = retained.record_id.clone();
20649        shared.insert_record(retained).unwrap();
20650        assert_eq!(
20651            shared.forget_context_pack(&context.id).unwrap_err().code,
20652            NodeFailureCode::ContextPackBusy,
20653        );
20654        assert!(shared
20655            .context_catalog
20656            .read()
20657            .unwrap_or_else(|poisoned| poisoned.into_inner())
20658            .get(&context.id)
20659            .is_some());
20660        shared
20661            .session_records
20662            .lock()
20663            .unwrap_or_else(|poisoned| poisoned.into_inner())
20664            .records
20665            .remove(&record_id);
20666
20667        let mut spec = spawn_spec_fixture();
20668        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20669            value: context.id.clone(),
20670        };
20671        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20672        let receipt = resolved.receipt_with_materialization(
20673            shared.incarnation_id,
20674            terminal_address(2),
20675            None,
20676            None,
20677            Some(context.clone()),
20678        );
20679        assert!(receipt.context_binding_is_valid());
20680        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20681
20682        shared.forget_context_pack(&context.id).unwrap();
20683        assert!(shared
20684            .context_catalog
20685            .read()
20686            .unwrap_or_else(|poisoned| poisoned.into_inner())
20687            .get(&context.id)
20688            .is_none());
20689        assert!(shared
20690            .replay_spawn_spec(&spec, Instant::now())
20691            .unwrap()
20692            .is_none());
20693    }
20694
20695    #[test]
20696    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20697        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20698        let address = terminal_address(1);
20699        let selection = shared
20700            .select_environment_profile(
20701                address.session.instance_id,
20702                &agent("claude"),
20703                SessionMode::Pty,
20704                Some(&environment_profile),
20705            )
20706            .unwrap()
20707            .unwrap();
20708        shared.bind_session_with_policy(
20709            &address,
20710            ProviderRuntimePolicy::raw_pty(),
20711            Some(environment_profile.clone()),
20712        );
20713        selection.retain();
20714        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20715        assert!(matches!(
20716            runtime.remove_native_launch_profile(&native_id),
20717            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20718        ));
20719        assert!(shared.remove_binding(&address).is_some());
20720        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20721
20722        let mismatched = ResolvedEnvironmentProfileReceipt {
20723            profile_id: environment_profile.profile_id,
20724            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20725                "local-claude-r2",
20726            )
20727            .unwrap(),
20728            network_allowlist: None,
20729            browser_profile_id: None,
20730        };
20731        let error = match shared.select_environment_profile(
20732                AgentInstanceId(42),
20733                &agent("claude"),
20734                SessionMode::Pty,
20735                Some(&mismatched),
20736            ) {
20737            Ok(_) => panic!("mismatched environment profile revision was selected"),
20738            Err(error) => error,
20739        };
20740        assert_eq!(
20741            error.code,
20742            NodeFailureCode::EnvironmentProfileBindingMismatch,
20743        );
20744        assert!(!shared
20745            .session_bindings
20746            .lock()
20747            .unwrap_or_else(|poisoned| poisoned.into_inner())
20748            .contains_key(&AgentInstanceId(42)));
20749    }
20750
20751    #[test]
20752    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20753        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20754        let address = terminal_address(1);
20755        let selection = shared
20756            .select_environment_profile(
20757                address.session.instance_id,
20758                &agent("claude"),
20759                SessionMode::Pty,
20760                Some(&environment_profile),
20761            )
20762            .unwrap()
20763            .unwrap();
20764        let overlay = NativeInstanceLaunchOverlay::new(
20765            agent("claude"),
20766            TransportKind::Pty,
20767            vec![EnvMutation {
20768                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20769                value: Some(OsString::from("fixture-secret")),
20770            }],
20771            vec![
20772                OsString::from("--plugin-dir"),
20773                std::env::current_dir().unwrap().into_os_string(),
20774            ],
20775        )
20776        .unwrap();
20777        let instance_overlay = shared
20778            .install_prepared_launch_overlay(
20779                address.session.instance_id,
20780                PreparedNativeLaunchOverlay::Instance(overlay),
20781            )
20782            .unwrap()
20783            .unwrap();
20784        let bundle = ResolvedBundleReceipt {
20785            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20786            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20787            digest: crate::protocol::SpawnBundleDigest::new(format!(
20788                "sha256:{}",
20789                "0".repeat(64),
20790            ))
20791            .unwrap(),
20792        };
20793        shared.bind_session_with_materialization(
20794            &address,
20795            ProviderRuntimePolicy::raw_pty(),
20796            Some(environment_profile),
20797            Some(bundle),
20798            None,
20799            None,
20800        );
20801        selection.retain();
20802        instance_overlay.retain();
20803
20804        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20805        assert!(matches!(
20806            runtime.remove_native_launch_profile(&native_id),
20807            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20808        ));
20809        assert!(shared.remove_binding(&address).is_some());
20810        assert!(!shared
20811            .native_launch_profile_control
20812            .as_ref()
20813            .unwrap()
20814            .clear_native_instance_launch_overlay(address.session.instance_id));
20815        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20816    }
20817
20818    #[test]
20819    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20820        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20821        let address = SessionAddress {
20822            workspace_id: WorkspaceId::new("primary").unwrap(),
20823            session: SessionKey {
20824                instance_id: AgentInstanceId(201),
20825                generation: SessionGeneration(1),
20826            },
20827        };
20828        let error = match shared.prepare_session_materialization(
20829            &address,
20830            &agent("claude"),
20831            SessionMode::Pty,
20832            Some(&receipt),
20833            None,
20834            None,
20835            None,
20836        ) {
20837            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20838            Err(error) => error,
20839        };
20840        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20841        assert!(shared.handle.snapshot().sessions.is_empty());
20842        assert!(shared
20843            .session_bindings
20844            .lock()
20845            .unwrap_or_else(|poisoned| poisoned.into_inner())
20846            .is_empty());
20847        assert!(shared
20848            .materializations
20849            .lock()
20850            .unwrap_or_else(|poisoned| poisoned.into_inner())
20851            .is_empty());
20852        drop(shared);
20853        drop(runtime);
20854        std::fs::remove_dir_all(root).unwrap();
20855    }
20856
20857    #[test]
20858    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20859        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20860        let address = SessionAddress {
20861            workspace_id: WorkspaceId::new("primary").unwrap(),
20862            session: SessionKey {
20863                instance_id: AgentInstanceId(202),
20864                generation: SessionGeneration(1),
20865            },
20866        };
20867        let (overlay, materialization_guard) = shared
20868            .prepare_session_materialization(
20869                &address,
20870                &agent("claude"),
20871                SessionMode::Pty,
20872                Some(&receipt),
20873                None,
20874                None,
20875                None,
20876            )
20877            .unwrap()
20878            .unwrap();
20879        let materialization_id = materialization_guard.id().unwrap().clone();
20880        let materialization_root = shared
20881            .materializations
20882            .lock()
20883            .unwrap_or_else(|poisoned| poisoned.into_inner())
20884            .get(&materialization_id)
20885            .unwrap()
20886            .root()
20887            .to_path_buf();
20888        let selection = shared
20889            .select_environment_profile(
20890                address.session.instance_id,
20891                &agent("claude"),
20892                SessionMode::Pty,
20893                Some(&receipt),
20894            )
20895            .unwrap()
20896            .unwrap();
20897        assert!(shared
20898            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20899            .unwrap()
20900            .is_none());
20901        shared.bind_session_with_materialization(
20902            &address,
20903            ProviderRuntimePolicy::raw_pty(),
20904            Some(receipt),
20905            None,
20906            None,
20907            Some(materialization_id),
20908        );
20909        selection.retain();
20910        materialization_guard.retain();
20911
20912        assert!(materialization_root.is_dir());
20913        assert!(shared
20914            .session_bindings
20915            .lock()
20916            .unwrap_or_else(|poisoned| poisoned.into_inner())
20917            .contains_key(&address.session.instance_id));
20918
20919        let removed = shared.remove_binding(&address).unwrap();
20920        shared
20921            .cleanup_session_owned_materialization(&address, &removed)
20922            .unwrap();
20923        assert!(!materialization_root.exists());
20924        assert!(shared
20925            .materializations
20926            .lock()
20927            .unwrap_or_else(|poisoned| poisoned.into_inner())
20928            .is_empty());
20929        drop(shared);
20930        drop(runtime);
20931        std::fs::remove_dir_all(root).unwrap();
20932    }
20933
20934    #[tokio::test]
20935    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20936        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20937        let address = SessionAddress {
20938            workspace_id: WorkspaceId::new("primary").unwrap(),
20939            session: SessionKey {
20940                instance_id: AgentInstanceId(203),
20941                generation: SessionGeneration(1),
20942            },
20943        };
20944        let (overlay, materialization_guard) = shared
20945            .prepare_session_materialization(
20946                &address,
20947                &agent("claude"),
20948                SessionMode::Pty,
20949                Some(&receipt),
20950                None,
20951                None,
20952                None,
20953            )
20954            .unwrap()
20955            .unwrap();
20956        let materialization_id = materialization_guard.id().unwrap().clone();
20957        let materialization_root = shared
20958            .materializations
20959            .lock()
20960            .unwrap_or_else(|poisoned| poisoned.into_inner())
20961            .get(&materialization_id)
20962            .unwrap()
20963            .root()
20964            .to_path_buf();
20965        let selection = shared
20966            .select_environment_profile(
20967                address.session.instance_id,
20968                &agent("claude"),
20969                SessionMode::Pty,
20970                Some(&receipt),
20971            )
20972            .unwrap()
20973            .unwrap();
20974        assert!(shared
20975            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20976            .unwrap()
20977            .is_none());
20978        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20979        let record_id = shared
20980            .bind_spawn_session_with_materialization(
20981                &address,
20982                agent("claude"),
20983                SessionMode::Pty,
20984                policy,
20985                SpawnRecordPolicy::ProviderIdentityOnly,
20986                Some(receipt.clone()),
20987                None,
20988                None,
20989                Some(materialization_id.clone()),
20990            )
20991            .unwrap()
20992            .unwrap();
20993        selection.retain();
20994        materialization_guard.retain();
20995        assert_eq!(
20996            shared
20997                .materializations
20998                .lock()
20999                .unwrap_or_else(|poisoned| poisoned.into_inner())
21000                .get(&materialization_id)
21001                .unwrap()
21002                .owner(),
21003            &MaterializationOwner::Record {
21004                record_id: record_id.clone(),
21005            },
21006        );
21007
21008        let removed = shared.remove_binding(&address).unwrap();
21009        shared
21010            .cleanup_session_owned_materialization(&address, &removed)
21011            .unwrap();
21012        assert!(materialization_root.is_dir());
21013        assert!(shared
21014            .resolve_record_materialization(
21015                &record_id,
21016                &agent("claude"),
21017                SessionMode::Pty,
21018                Some(&receipt),
21019                None,
21020                None,
21021            )
21022            .unwrap()
21023            .is_some());
21024        {
21025            let mut records = shared
21026                .session_records
21027                .lock()
21028                .unwrap_or_else(|poisoned| poisoned.into_inner());
21029            let record = records.records.get_mut(&record_id).unwrap();
21030            record.active_session = None;
21031            record.state = ManagedSessionState::Unavailable;
21032        }
21033        shared.forget_session_record(&record_id).await.unwrap();
21034        assert!(!materialization_root.exists());
21035        assert!(shared
21036            .materializations
21037            .lock()
21038            .unwrap_or_else(|poisoned| poisoned.into_inner())
21039            .is_empty());
21040        drop(shared);
21041        drop(runtime);
21042        std::fs::remove_dir_all(root).unwrap();
21043    }
21044
21045    #[test]
21046    fn record_materialization_revalidation_failure_persists_recovery_required() {
21047        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21048        let state_path = root.join("node-state-v6.json");
21049        shared.state_path = Some(state_path.clone());
21050        let (_address, _record_id, materialization_id, materialization_root) =
21051            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21052        std::fs::write(
21053            materialization_root.join(".gate4agent-materialization-owner"),
21054            b"tampered",
21055        )
21056        .unwrap();
21057
21058        let error = match shared.resolve_record_materialization(
21059            &_record_id,
21060            &agent("claude"),
21061            SessionMode::Pty,
21062            Some(&receipt),
21063            None,
21064            None,
21065        ) {
21066            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21067            Err(error) => error,
21068        };
21069        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21070        assert_eq!(
21071            shared
21072                .materializations
21073                .lock()
21074                .unwrap_or_else(|poisoned| poisoned.into_inner())
21075                .get(&materialization_id)
21076                .unwrap()
21077                .state(),
21078            MaterializationState::RecoveryRequired,
21079        );
21080        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21081        assert_eq!(loaded.materializations.len(), 1);
21082        assert_eq!(
21083            loaded.materializations[0].state(),
21084            MaterializationState::RecoveryRequired,
21085        );
21086        drop(shared);
21087        drop(runtime);
21088        std::fs::remove_dir_all(root).unwrap();
21089    }
21090
21091    #[test]
21092    fn transient_record_materialization_resolution_failure_remains_ready() {
21093        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21094        let (_address, record_id, materialization_id, _materialization_root) =
21095            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21096        deny.store(true, Ordering::Release);
21097
21098        let error = match shared.resolve_record_materialization(
21099            &record_id,
21100            &agent("claude"),
21101            SessionMode::Pty,
21102            Some(&receipt),
21103            None,
21104            None,
21105        ) {
21106            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21107            Err(error) => error,
21108        };
21109        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21110        assert_eq!(
21111            shared
21112                .materializations
21113                .lock()
21114                .unwrap_or_else(|poisoned| poisoned.into_inner())
21115                .get(&materialization_id)
21116                .unwrap()
21117                .state(),
21118            MaterializationState::Ready,
21119        );
21120        drop(shared);
21121        drop(runtime);
21122        std::fs::remove_dir_all(root).unwrap();
21123    }
21124
21125    #[test]
21126    fn startup_missing_record_materialization_is_not_resumable() {
21127        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21128        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21129        shared
21130            .insert_record(ManagedSessionRecord {
21131                record_id: record_id.clone(),
21132                display_name: "missing materialization".to_owned(),
21133                provider: agent("claude"),
21134                mode: SessionMode::Pty,
21135                state: ManagedSessionState::Dormant,
21136                workspace_id: WorkspaceId::new("primary").unwrap(),
21137                canonical_root: opaque_windows_path(
21138                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21139                ),
21140                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21141                    key: gate4agent_types::ProviderSessionKey::SessionId,
21142                    id: "provider-session-missing-materialization".to_owned(),
21143                    transcript_path: None,
21144                }),
21145                active_session: None,
21146                environment_profile: Some(receipt),
21147                bundle: None,
21148                context_id: None,
21149                context: None,
21150                exported_context: None,
21151                task_binding: None,
21152                created_at_unix_ms: 1,
21153                updated_at_unix_ms: 1,
21154                last_error: None,
21155            })
21156            .unwrap();
21157
21158        shared.reconcile_materializations();
21159        let record = shared.record(&record_id).unwrap();
21160        assert_eq!(record.state, ManagedSessionState::Unavailable);
21161        assert!(record.provider_session.is_none());
21162        assert_eq!(
21163            record.last_error.as_deref(),
21164            Some("environment-profile-unavailable"),
21165        );
21166        drop(shared);
21167        drop(runtime);
21168        std::fs::remove_dir_all(root).unwrap();
21169    }
21170
21171    #[tokio::test]
21172    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21173        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21174        let lease = install_managed_lease(&shared);
21175        let address = SessionAddress {
21176            workspace_id: WorkspaceId::new("primary").unwrap(),
21177            session: SessionKey {
21178                instance_id: AgentInstanceId(204),
21179                generation: SessionGeneration(1),
21180            },
21181        };
21182        let (overlay, materialization_guard) = shared
21183            .prepare_session_materialization(
21184                &address,
21185                &agent("claude"),
21186                SessionMode::Pty,
21187                Some(&receipt),
21188                None,
21189                None,
21190                Some(lease.lease_id.clone()),
21191            )
21192            .unwrap()
21193            .unwrap();
21194        let materialization_id = materialization_guard.id().unwrap().clone();
21195        let materialization_root = shared
21196            .materializations
21197            .lock()
21198            .unwrap_or_else(|poisoned| poisoned.into_inner())
21199            .get(&materialization_id)
21200            .unwrap()
21201            .root()
21202            .to_path_buf();
21203        let selection = shared
21204            .select_environment_profile(
21205                address.session.instance_id,
21206                &agent("claude"),
21207                SessionMode::Pty,
21208                Some(&receipt),
21209            )
21210            .unwrap()
21211            .unwrap();
21212        assert!(shared
21213            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21214            .unwrap()
21215            .is_none());
21216        shared.bind_session_with_materialization(
21217            &address,
21218            ProviderRuntimePolicy::raw_pty(),
21219            Some(receipt),
21220            None,
21221            None,
21222            Some(materialization_id),
21223        );
21224        selection.retain();
21225        materialization_guard.retain();
21226
21227        let blocked = shared
21228            .cleanup_managed_worktree(&lease.lease_id, false)
21229            .await
21230            .unwrap_err();
21231        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21232        assert!(materialization_root.is_dir());
21233        let removed = shared.remove_binding(&address).unwrap();
21234        shared
21235            .cleanup_session_owned_materialization(&address, &removed)
21236            .unwrap();
21237        assert!(!materialization_root.exists());
21238        drop(shared);
21239        drop(runtime);
21240        std::fs::remove_dir_all(root).unwrap();
21241    }
21242
21243    #[tokio::test]
21244    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21245        let git_root = temporary_workspace_root("raw-managed-inventory");
21246        let source = git_root.join("source");
21247        let allocation = git_root.join("allocation");
21248        std::fs::create_dir_all(&source).unwrap();
21249        std::fs::create_dir_all(&allocation).unwrap();
21250        run_test_git(&source, &["init"]);
21251        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21252        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21253        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21254        run_test_git(&source, &["add", "seed.txt"]);
21255        run_test_git(&source, &["commit", "-m", "seed"]);
21256        let profile = ManagedWorktreeProfile::new(
21257            WorktreeProfileId::new("default").unwrap(),
21258            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21259            &allocation,
21260            "gate4agent",
21261            "HEAD",
21262            ManagedWorktreeRetention::RemoveWhenReleased,
21263        )
21264        .unwrap();
21265        let workspace = WorkspaceConfig::new(
21266            WorkspaceId::new("primary").unwrap(),
21267            &source,
21268        )
21269        .unwrap()
21270        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21271        .with_managed_worktree_profile(profile.clone())
21272        .unwrap();
21273        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21274            materialization_test_shared_with_workspace(false, workspace);
21275        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21276        let source_text = source.to_string_lossy().into_owned();
21277        let target_text = target.to_string_lossy().into_owned();
21278        let created = create_git_worktree(
21279            &source_text,
21280            &target_text,
21281            "gate4agent/mw-raw-inventory",
21282            Some("HEAD"),
21283        )
21284        .await
21285        .unwrap();
21286        let lease = ManagedWorktreeLeaseRecord {
21287            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21288            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21289            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21290            profile_id: profile.profile_id().clone(),
21291            profile_revision: profile.revision().clone(),
21292            target_root: created.path.clone(),
21293            branch: "gate4agent/mw-raw-inventory".to_owned(),
21294            base_commit: created.head.clone(),
21295            expected_head: Some(created.head.clone()),
21296            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21297            state: ManagedWorktreeLeaseState::Ready,
21298            session_holders: Vec::new(),
21299            record_holders: Vec::new(),
21300            cleanup_failure: None,
21301            created_at_unix_ms: 1,
21302            updated_at_unix_ms: 1,
21303        };
21304        shared
21305            .workspaces
21306            .write()
21307            .unwrap_or_else(|poisoned| poisoned.into_inner())
21308            .insert(lease.workspace_id.clone(), created.path.clone());
21309        *shared
21310            .managed_worktrees
21311            .lock()
21312            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21313            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21314        let address = SessionAddress {
21315            workspace_id: lease.workspace_id.clone(),
21316            session: SessionKey {
21317                instance_id: AgentInstanceId(205),
21318                generation: SessionGeneration(1),
21319            },
21320        };
21321        let (overlay, materialization_guard) = shared
21322            .prepare_session_materialization(
21323                &address,
21324                &agent("claude"),
21325                SessionMode::Pty,
21326                Some(&receipt),
21327                None,
21328                None,
21329                Some(lease.lease_id.clone()),
21330            )
21331            .unwrap()
21332            .unwrap();
21333        let materialization_id = materialization_guard.id().unwrap().clone();
21334        let materialization_root = shared
21335            .materializations
21336            .lock()
21337            .unwrap_or_else(|poisoned| poisoned.into_inner())
21338            .get(&materialization_id)
21339            .unwrap()
21340            .root()
21341            .to_path_buf();
21342        let selection = shared
21343            .select_environment_profile(
21344                address.session.instance_id,
21345                &agent("claude"),
21346                SessionMode::Pty,
21347                Some(&receipt),
21348            )
21349            .unwrap()
21350            .unwrap();
21351        assert!(shared
21352            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21353            .unwrap()
21354            .is_none());
21355        let record_id = shared
21356            .bind_spawn_session_with_materialization(
21357                &address,
21358                agent("claude"),
21359                SessionMode::Pty,
21360                ProviderRuntimePolicy::raw_pty(),
21361                SpawnRecordPolicy::Always,
21362                Some(receipt.clone()),
21363                None,
21364                None,
21365                Some(materialization_id.clone()),
21366            )
21367            .unwrap()
21368            .unwrap();
21369        selection.retain();
21370        materialization_guard.retain();
21371        let record = shared.record(&record_id).unwrap();
21372        assert_eq!(record.state, ManagedSessionState::Live);
21373        assert!(record.provider_session.is_none());
21374        assert!(record.environment_profile.is_none());
21375        assert!(record.bundle.is_none());
21376        assert!(record.context_id.is_none());
21377        assert!(record.context.is_none());
21378        assert_eq!(record.active_session.as_ref(), Some(&address));
21379        assert_eq!(
21380            shared
21381                .materializations
21382                .lock()
21383                .unwrap_or_else(|poisoned| poisoned.into_inner())
21384                .get(&materialization_id)
21385                .unwrap()
21386                .owner(),
21387            &MaterializationOwner::Session {
21388                incarnation_id: shared.incarnation_id,
21389                instance_id: address.session.instance_id,
21390                generation: address.session.generation,
21391            },
21392        );
21393        {
21394            let mut bindings = shared
21395                .session_bindings
21396                .lock()
21397                .unwrap_or_else(|poisoned| poisoned.into_inner());
21398            bindings
21399                .get_mut(&address.session.instance_id)
21400                .unwrap()
21401                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21402        }
21403        let lease_snapshot = shared
21404            .managed_worktrees
21405            .lock()
21406            .unwrap_or_else(|poisoned| poisoned.into_inner())
21407            .bind_session(
21408                &lease.lease_id,
21409                ManagedWorktreeSessionHolder {
21410                    incarnation_id: shared.incarnation_id,
21411                    instance_id: address.session.instance_id,
21412                    generation: address.session.generation,
21413                },
21414                None,
21415                unix_time_ms(),
21416            )
21417            .unwrap();
21418        assert_eq!(lease_snapshot.active_session_count, 1);
21419        assert_eq!(lease_snapshot.managed_record_count, 0);
21420        shared.persist_state().unwrap();
21421        assert!(shared
21422            .managed_worktrees
21423            .lock()
21424            .unwrap_or_else(|poisoned| poisoned.into_inner())
21425            .get(&lease.lease_id)
21426            .unwrap()
21427            .record_holders
21428            .is_empty());
21429        shared
21430            .handle
21431            .dispatch(shared.prepare_command(ControlCommand::Register {
21432                instance_id: address.session.instance_id,
21433                agent_id: agent("claude"),
21434                transport: TransportKind::Pty,
21435            }))
21436            .unwrap();
21437        runtime.tick().await;
21438        shared.reconcile_managed_record(
21439            &record_id,
21440            &address,
21441            &ControlEvent {
21442                sequence: 1,
21443                command_id: None,
21444                instance_id: address.session.instance_id,
21445                generation: address.session.generation,
21446                event: ControlEventKind::Removed,
21447            },
21448            false,
21449            false,
21450        );
21451        let remove = shared.remove_session(&address);
21452        let drive_runtime = async {
21453            runtime.tick().await;
21454        };
21455        let (removed, ()) = tokio::join!(remove, drive_runtime);
21456        removed.unwrap();
21457
21458        assert!(!materialization_root.exists());
21459        assert!(!Path::new(&created.path).exists());
21460        assert_eq!(
21461            shared
21462                .managed_worktrees
21463                .lock()
21464                .unwrap_or_else(|poisoned| poisoned.into_inner())
21465                .get(&lease.lease_id)
21466                .unwrap()
21467                .state,
21468            ManagedWorktreeLeaseState::Removed,
21469        );
21470        let renamed = shared
21471            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21472            .unwrap();
21473        assert_eq!(renamed.record_id, record_id);
21474        assert_eq!(renamed.display_name, "raw managed inventory");
21475        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21476        assert!(renamed.active_session.is_none());
21477
21478        drop(shared);
21479        drop(runtime);
21480        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21481        std::fs::remove_dir_all(git_root).unwrap();
21482    }
21483
21484    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21485        let root = std::env::current_dir().unwrap()
21486            .join("managed-test-target")
21487            .to_string_lossy()
21488            .into_owned();
21489        let lease = ManagedWorktreeLeaseRecord {
21490            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21491            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21492            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21493            profile_id: WorktreeProfileId::new("default").unwrap(),
21494            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21495            target_root: root.clone(),
21496            branch: "gate4agent/mw-test".to_owned(),
21497            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21498            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21499            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21500            state: ManagedWorktreeLeaseState::Ready,
21501            session_holders: Vec::new(),
21502            record_holders: Vec::new(),
21503            cleanup_failure: None,
21504            created_at_unix_ms: 1,
21505            updated_at_unix_ms: 1,
21506        };
21507        shared.workspaces.write()
21508            .unwrap_or_else(|poisoned| poisoned.into_inner())
21509            .insert(lease.workspace_id.clone(), root);
21510        *shared.managed_worktrees.lock()
21511            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21512            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21513        lease
21514    }
21515
21516    #[cfg(feature = "fixture")]
21517    #[test]
21518    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21519        let mut shared = terminal_test_shared();
21520        shared.fixture_semantic_hook_policy = true;
21521        let lease = install_managed_lease(&shared);
21522        let address = SessionAddress {
21523            workspace_id: lease.workspace_id.clone(),
21524            session: SessionKey {
21525                instance_id: AgentInstanceId(206),
21526                generation: SessionGeneration(1),
21527            },
21528        };
21529        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21530        let effective_runtime_policy =
21531            shared.effective_spawn_runtime_policy(pre_admission_policy);
21532        assert_ne!(effective_runtime_policy, pre_admission_policy);
21533        assert!(effective_runtime_policy.provider_session_identity);
21534
21535        let record_id = shared
21536            .bind_spawn_session(
21537                &address,
21538                agent("claude"),
21539                SessionMode::Pty,
21540                effective_runtime_policy,
21541                SpawnRecordPolicy::Always,
21542                None,
21543            )
21544            .unwrap()
21545            .unwrap();
21546        assert_eq!(
21547            shared.record(&record_id).unwrap().state,
21548            ManagedSessionState::IdentityPending,
21549        );
21550        let (record_holder, raw_inventory_record_id) = {
21551            let mut bindings = shared
21552                .session_bindings
21553                .lock()
21554                .unwrap_or_else(|poisoned| poisoned.into_inner());
21555            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21556            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21557            managed_spawn_record_ownership(binding, effective_runtime_policy)
21558        };
21559        assert_eq!(record_holder.as_ref(), Some(&record_id));
21560        assert!(raw_inventory_record_id.is_none());
21561        let snapshot = shared
21562            .managed_worktrees
21563            .lock()
21564            .unwrap_or_else(|poisoned| poisoned.into_inner())
21565            .bind_session(
21566                &lease.lease_id,
21567                ManagedWorktreeSessionHolder {
21568                    incarnation_id: shared.incarnation_id,
21569                    instance_id: address.session.instance_id,
21570                    generation: address.session.generation,
21571                },
21572                record_holder,
21573                unix_time_ms(),
21574            )
21575            .unwrap();
21576        assert_eq!(snapshot.managed_record_count, 1);
21577        assert_eq!(
21578            shared
21579                .managed_worktrees
21580                .lock()
21581                .unwrap_or_else(|poisoned| poisoned.into_inner())
21582                .get(&lease.lease_id)
21583                .unwrap()
21584                .record_holders,
21585            vec![record_id.clone()],
21586        );
21587
21588        let identity = ProviderSessionIdentity {
21589            key: ProviderSessionKey::SessionId,
21590            id: "monitoring-fixture-managed-session".to_owned(),
21591            transcript_path: None,
21592        };
21593        shared.publish_control(ControlEvent {
21594            sequence: 1,
21595            command_id: None,
21596            instance_id: address.session.instance_id,
21597            generation: address.session.generation,
21598            event: ControlEventKind::ProviderEvent {
21599                sequence: 1,
21600                source: gate4agent_types::ProviderSource {
21601                    family: AdapterFamily::Hook,
21602                    binding: AdapterBinding::new(
21603                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21604                        "fixture/v1",
21605                        gate4agent_types::AdapterVerification::SyntheticFixture,
21606                    )
21607                    .unwrap(),
21608                },
21609                source_sequence: 1,
21610                event: ProviderEvent::SessionIdentityObserved {
21611                    identity: identity.clone(),
21612                },
21613            },
21614        });
21615
21616        let live = shared.record(&record_id).unwrap();
21617        assert_eq!(live.state, ManagedSessionState::Live);
21618        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21619        assert_eq!(live.active_session.as_ref(), Some(&address));
21620        assert_eq!(
21621            shared
21622                .managed_worktrees
21623                .lock()
21624                .unwrap_or_else(|poisoned| poisoned.into_inner())
21625                .get(&lease.lease_id)
21626                .unwrap()
21627                .record_holders,
21628            vec![record_id],
21629        );
21630    }
21631
21632    fn managed_v2_test_shared(
21633        allocation_root: &Path,
21634        state_path: Option<PathBuf>,
21635        profile_revision: &str,
21636    ) -> NodeShared {
21637        let profile = ManagedWorktreeProfile::new(
21638            WorktreeProfileId::new("default").unwrap(),
21639            WorktreeProfileRevision::new(profile_revision).unwrap(),
21640            allocation_root,
21641            "gate4agent",
21642            "HEAD",
21643            ManagedWorktreeRetention::RemoveWhenReleased,
21644        )
21645        .unwrap();
21646        let workspace = WorkspaceConfig::new(
21647            WorkspaceId::new("primary").unwrap(),
21648            std::env::current_dir().unwrap(),
21649        )
21650        .unwrap()
21651        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21652        .with_managed_worktree_profile(profile)
21653        .unwrap();
21654        let catalog = active_registry().unwrap();
21655        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21656        let mut shared = NodeShared::new(
21657            handle,
21658            "fixture-token".to_owned(),
21659            NodeId::new("node-terminal-test").unwrap(),
21660            vec![workspace],
21661            vec![agent("claude")],
21662        );
21663        shared.state_path = state_path;
21664        shared
21665    }
21666
21667    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21668        let mut spawn_spec = spawn_spec_fixture();
21669        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21670        ManagedWorktreeSpawnRequestV2 {
21671            spawn_spec,
21672            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21673            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21674        }
21675    }
21676
21677    #[test]
21678    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21679        let shared = terminal_test_shared();
21680        let mut lease = install_managed_lease(&shared);
21681        let session = SessionAddress {
21682            workspace_id: lease.workspace_id.clone(),
21683            session: SessionKey {
21684                instance_id: AgentInstanceId(73),
21685                generation: SessionGeneration(4),
21686            },
21687        };
21688        lease.state = ManagedWorktreeLeaseState::InUse;
21689        lease.session_holders.push(ManagedWorktreeSessionHolder {
21690            incarnation_id: shared.incarnation_id,
21691            instance_id: session.session.instance_id,
21692            generation: session.session.generation,
21693        });
21694        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21695        assert!(resolved.target.worktree_id.is_none());
21696
21697        let receipt = managed_spawn_receipt(
21698            &resolved,
21699            shared.incarnation_id,
21700            session.clone(),
21701            lease.snapshot(),
21702            None,
21703            None,
21704            None,
21705        );
21706        assert!(resolved.target.worktree_id.is_none());
21707        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21708        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21709        assert_eq!(receipt.spawn.session, session);
21710        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21711        let encoded = serde_json::to_vec(&receipt).unwrap();
21712        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21713        assert_eq!(decoded, receipt);
21714    }
21715
21716    #[tokio::test]
21717    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21718        let root = temporary_workspace_root("managed-v2-revision");
21719        let allocation = root.join("allocation");
21720        std::fs::create_dir_all(&allocation).unwrap();
21721        let shared = managed_v2_test_shared(
21722            &allocation,
21723            Some(root.join("node-state.json")),
21724            "v1",
21725        );
21726        let mut request = managed_v2_request("managed-v2-revision");
21727        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21728
21729        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21730
21731        assert_eq!(
21732            failure.code,
21733            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21734        );
21735        assert!(shared
21736            .managed_worktrees
21737            .lock()
21738            .unwrap_or_else(|poisoned| poisoned.into_inner())
21739            .records()
21740            .is_empty());
21741        assert!(shared
21742            .managed_spawn_replays
21743            .lock()
21744            .unwrap_or_else(|poisoned| poisoned.into_inner())
21745            .is_empty());
21746        std::fs::remove_dir_all(root).unwrap();
21747    }
21748
21749    #[test]
21750    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21751        let root = temporary_workspace_root("managed-v2-concurrent");
21752        let allocation = root.join("allocation");
21753        std::fs::create_dir_all(&allocation).unwrap();
21754        let shared = Arc::new(managed_v2_test_shared(
21755            &allocation,
21756            Some(root.join("node-state.json")),
21757            "v1",
21758        ));
21759        let request = managed_v2_request("managed-v2-concurrent");
21760        let barrier = Arc::new(std::sync::Barrier::new(3));
21761        let mut joins = Vec::new();
21762        for _ in 0..2 {
21763            let shared = Arc::clone(&shared);
21764            let request = request.clone();
21765            let barrier = Arc::clone(&barrier);
21766            joins.push(std::thread::spawn(move || {
21767                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21768                barrier.wait();
21769                shared.acquire_durable_managed_spawn_v2(&request, digest)
21770            }));
21771        }
21772        barrier.wait();
21773        let results = joins
21774            .into_iter()
21775            .map(|join| join.join().unwrap())
21776            .collect::<Vec<_>>();
21777        assert_eq!(
21778            results
21779                .iter()
21780                .filter(|result| {
21781                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21782                })
21783                .count(),
21784            1,
21785        );
21786        assert_eq!(
21787            results
21788                .iter()
21789                .filter(|result| {
21790                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21791                })
21792                .count(),
21793            1,
21794        );
21795        assert_eq!(
21796            shared
21797                .managed_spawn_replays
21798                .lock()
21799                .unwrap_or_else(|poisoned| poisoned.into_inner())
21800                .len(),
21801            1,
21802        );
21803        assert!(shared
21804            .managed_worktrees
21805            .lock()
21806            .unwrap_or_else(|poisoned| poisoned.into_inner())
21807            .records()
21808            .is_empty());
21809        drop(shared);
21810        std::fs::remove_dir_all(root).unwrap();
21811    }
21812
21813    #[tokio::test]
21814    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21815        let root = temporary_workspace_root("managed-v2-linked-pending");
21816        let allocation = root.join("allocation");
21817        std::fs::create_dir_all(&allocation).unwrap();
21818        let state_path = root.join("node-state.json");
21819        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21820        let lease = install_managed_lease(&shared);
21821        let request = managed_v2_request("managed-v2-linked-pending");
21822        assert!(matches!(
21823            shared
21824                .acquire_durable_managed_spawn_v2(
21825                    &request,
21826                    managed_spawn_request_digest_v2(&request).unwrap(),
21827                )
21828                .unwrap(),
21829            DurableManagedSpawnReplayDecision::Reserved,
21830        ));
21831        {
21832            let _transaction = shared
21833                .state_transaction
21834                .lock()
21835                .unwrap_or_else(|poisoned| poisoned.into_inner());
21836            shared
21837                .link_durable_managed_spawn_v2_lease_locked(
21838                    &request.spawn_spec.idempotency_key,
21839                    &lease.lease_id,
21840                )
21841                .unwrap();
21842            shared.persist_state_locked().unwrap();
21843        }
21844        drop(shared);
21845
21846        let mut loaded = session_registry::load(
21847            Some(&state_path),
21848            &NodeId::new("node-terminal-test").unwrap(),
21849        )
21850        .unwrap();
21851        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21852        *reopened
21853            .managed_worktrees
21854            .lock()
21855            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21856            ManagedWorktreeRegistry::from_records(
21857                std::mem::take(&mut loaded.managed_worktrees),
21858                std::mem::take(&mut loaded.managed_worktree_tombstones),
21859            )
21860            .unwrap();
21861        *reopened
21862            .managed_spawn_replays
21863            .lock()
21864            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21865            .managed_spawn_replays
21866            .into_iter()
21867            .map(|record| (record.idempotency_key.clone(), record))
21868            .collect();
21869        let lease_count = reopened
21870            .managed_worktrees
21871            .lock()
21872            .unwrap_or_else(|poisoned| poisoned.into_inner())
21873            .records()
21874            .len();
21875
21876        let failure = reopened
21877            .spawn_managed_worktree_v2(request)
21878            .await
21879            .unwrap_err();
21880
21881        assert_eq!(
21882            failure.code,
21883            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21884        );
21885        assert_eq!(
21886            reopened
21887                .managed_worktrees
21888                .lock()
21889                .unwrap_or_else(|poisoned| poisoned.into_inner())
21890                .records()
21891                .len(),
21892            lease_count,
21893        );
21894        std::fs::remove_dir_all(root).unwrap();
21895    }
21896
21897    #[tokio::test]
21898    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21899        let root = temporary_workspace_root("managed-v2-reopen");
21900        let allocation = root.join("allocation");
21901        std::fs::create_dir_all(&allocation).unwrap();
21902        let state_path = root.join("node-state.json");
21903        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21904        let lease = install_managed_lease(&shared);
21905        let request = managed_v2_request("managed-v2-reopen");
21906        let session = SessionAddress {
21907            workspace_id: lease.workspace_id.clone(),
21908            session: SessionKey {
21909                instance_id: AgentInstanceId(91),
21910                generation: SessionGeneration(1),
21911            },
21912        };
21913        let snapshot = {
21914            let mut registry = shared
21915                .managed_worktrees
21916                .lock()
21917                .unwrap_or_else(|poisoned| poisoned.into_inner());
21918            let current = registry.get_mut(&lease.lease_id).unwrap();
21919            current.state = ManagedWorktreeLeaseState::InUse;
21920            current.session_holders.push(ManagedWorktreeSessionHolder {
21921                incarnation_id: shared.incarnation_id,
21922                instance_id: session.session.instance_id,
21923                generation: session.session.generation,
21924            });
21925            current.snapshot()
21926        };
21927        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21928        let receipt = managed_spawn_receipt(
21929            &resolved,
21930            shared.incarnation_id,
21931            session,
21932            snapshot,
21933            None,
21934            None,
21935            None,
21936        );
21937        assert!(matches!(
21938            shared
21939                .acquire_durable_managed_spawn_v2(
21940                    &request,
21941                    managed_spawn_request_digest_v2(&request).unwrap(),
21942                )
21943                .unwrap(),
21944            DurableManagedSpawnReplayDecision::Reserved,
21945        ));
21946        {
21947            let _transaction = shared
21948                .state_transaction
21949                .lock()
21950                .unwrap_or_else(|poisoned| poisoned.into_inner());
21951            shared
21952                .link_durable_managed_spawn_v2_lease_locked(
21953                    &request.spawn_spec.idempotency_key,
21954                    &lease.lease_id,
21955                )
21956                .unwrap();
21957            shared.persist_state_locked().unwrap();
21958        }
21959        shared
21960            .commit_durable_managed_spawn_v2(&request, receipt.clone())
21961            .unwrap();
21962        let legacy_same_key = ManagedWorktreeSpawnRequest {
21963            spawn_spec: request.spawn_spec.clone(),
21964            worktree_profile_id: request.worktree_profile_id.clone(),
21965        };
21966        shared.remember_managed_spawn_attempt(
21967            Some(&request.spawn_spec.idempotency_key),
21968            legacy_same_key.clone(),
21969            Ok(receipt.clone()),
21970            Instant::now(),
21971        );
21972        assert!(shared
21973            .replay_managed_spawn(&legacy_same_key, Instant::now())
21974            .unwrap()
21975            .is_none());
21976        drop(shared);
21977
21978        let mut loaded = session_registry::load(
21979            Some(&state_path),
21980            &NodeId::new("node-terminal-test").unwrap(),
21981        )
21982        .unwrap();
21983        assert_eq!(loaded.managed_spawn_replays.len(), 1);
21984        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21985        *reopened
21986            .managed_worktrees
21987            .lock()
21988            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21989            ManagedWorktreeRegistry::from_records(
21990                std::mem::take(&mut loaded.managed_worktrees),
21991                std::mem::take(&mut loaded.managed_worktree_tombstones),
21992            )
21993            .unwrap();
21994        *reopened
21995            .managed_spawn_replays
21996            .lock()
21997            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21998            .managed_spawn_replays
21999            .into_iter()
22000            .map(|record| (record.idempotency_key.clone(), record))
22001            .collect();
22002        let lease_count_before = reopened
22003            .managed_worktrees
22004            .lock()
22005            .unwrap_or_else(|poisoned| poisoned.into_inner())
22006            .records()
22007            .len();
22008
22009        assert_eq!(
22010            reopened
22011                .spawn_managed_worktree_v2(request.clone())
22012                .await
22013                .unwrap(),
22014            receipt,
22015        );
22016        assert_eq!(
22017            reopened
22018                .managed_worktrees
22019                .lock()
22020                .unwrap_or_else(|poisoned| poisoned.into_inner())
22021                .records()
22022                .len(),
22023            lease_count_before,
22024        );
22025        let mut changed = request;
22026        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22027        let failure = reopened
22028            .spawn_managed_worktree_v2(changed)
22029            .await
22030            .unwrap_err();
22031        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22032        assert_eq!(
22033            reopened
22034                .managed_worktrees
22035                .lock()
22036                .unwrap_or_else(|poisoned| poisoned.into_inner())
22037                .records()
22038                .len(),
22039            lease_count_before,
22040        );
22041        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22042        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22043        let failure = reopened
22044            .spawn_managed_worktree_v2(new_key)
22045            .await
22046            .unwrap_err();
22047        assert_eq!(
22048            failure.code,
22049            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22050        );
22051        assert_eq!(
22052            reopened
22053                .managed_worktrees
22054                .lock()
22055                .unwrap_or_else(|poisoned| poisoned.into_inner())
22056                .records()
22057                .len(),
22058            lease_count_before,
22059        );
22060        assert_eq!(
22061            reopened
22062                .managed_spawn_replays
22063                .lock()
22064                .unwrap_or_else(|poisoned| poisoned.into_inner())
22065                .len(),
22066            1,
22067        );
22068        std::fs::remove_dir_all(root).unwrap();
22069    }
22070
22071    #[test]
22072    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22073        let shared = terminal_test_shared();
22074        let request = ManagedWorktreeSpawnRequest {
22075            spawn_spec: spawn_spec_fixture(),
22076            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22077        };
22078        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22079        shared.remember_managed_spawn(
22080            request.clone(),
22081            Err(expected.clone()),
22082            Instant::now(),
22083        );
22084
22085        assert_eq!(
22086            shared
22087                .replay_managed_spawn(&request, Instant::now())
22088                .unwrap()
22089                .unwrap(),
22090            Err(expected),
22091        );
22092        assert!(shared
22093            .managed_spawn_replays
22094            .lock()
22095            .unwrap_or_else(|poisoned| poisoned.into_inner())
22096            .is_empty());
22097    }
22098
22099    #[tokio::test]
22100    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22101        let shared = terminal_test_shared();
22102        let lease = install_managed_lease(&shared);
22103        let raw = shared.spawn_session(
22104            lease.workspace_id.clone(),
22105            agent("claude"),
22106            SessionMode::Pty,
22107            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22108            None,
22109        ).await.unwrap_err();
22110        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22111        let mut spec = spawn_spec_fixture();
22112        spec.target.workspace_id = lease.workspace_id.clone();
22113        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22114        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22115        let mut selected = spawn_spec_fixture();
22116        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22117        selected.target.worktree_id = Some(lease.workspace_id);
22118        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22119        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22120        assert!(shared.handle.snapshot().sessions.is_empty());
22121    }
22122
22123    #[test]
22124    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22125        let shared = terminal_test_shared();
22126        let lease = install_managed_lease(&shared);
22127        let snapshot = snapshot_for_wire(
22128            &shared, true, true, true, false, true, true, false, false, true,
22129        );
22130        assert!(snapshot.managed_worktrees.is_empty());
22131        assert!(snapshot.workspaces.iter().all(|workspace| {
22132            workspace.managed_worktree_profiles.is_none()
22133        }));
22134        let mut reply = ResponseEnvelope {
22135            request_id: 1,
22136            result: Ok(NodeResponse::Resync {
22137                event_sequence: 1,
22138                oldest_available_sequence: 1,
22139                snapshot: shared.snapshot(),
22140                events: vec![NodeEventEnvelope {
22141                    sequence: 1,
22142                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22143                }],
22144            }),
22145        };
22146        project_response_without_managed_worktrees(&mut reply);
22147        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22148            panic!("projection changed the response kind");
22149        };
22150        assert!(snapshot.managed_worktrees.is_empty());
22151        assert!(events.is_empty());
22152        let mut git = git_snapshot_for_parser();
22153        git.branch = Some(lease.branch.clone());
22154        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22155            lease_id: lease.lease_id.clone(),
22156            source_workspace_id: lease.source_workspace_id.clone(),
22157            branch: lease.branch.clone(),
22158            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22159            active_session_count: 1,
22160            managed_record_count: 0,
22161        });
22162        let mut inspection_reply = ResponseEnvelope {
22163            request_id: 2,
22164            result: Ok(NodeResponse::WorkspaceInspected {
22165                inspection: WorkspaceInspection {
22166                    workspace_id: lease.workspace_id.clone(),
22167                    entries: Vec::new(),
22168                    tree_truncated: false,
22169                    git,
22170                    truncation: None,
22171                },
22172            }),
22173        };
22174        project_response_without_managed_worktrees(&mut inspection_reply);
22175        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22176            panic!("projection changed the inspection response kind");
22177        };
22178        assert!(inspection.git.managed_worktree.is_none());
22179        let failure = managed_git_worktree_failure(GitWorktreeError {
22180            kind: GitWorktreeErrorKind::Failed,
22181            message: r"git failed at C:\private\managed-target".to_owned(),
22182        });
22183        assert!(!failure.message.contains("private"));
22184        assert!(!failure.message.contains("managed-target"));
22185    }
22186
22187    #[tokio::test]
22188    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22189        let root = temporary_workspace_root("managed-restart");
22190        let source = root.join("source");
22191        let allocation = root.join("allocation");
22192        std::fs::create_dir_all(&source).unwrap();
22193        std::fs::create_dir_all(&allocation).unwrap();
22194        run_test_git(&source, &["init"]);
22195        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22196        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22197        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22198        run_test_git(&source, &["add", "seed.txt"]);
22199        run_test_git(&source, &["commit", "-m", "seed"]);
22200        let profile = ManagedWorktreeProfile::new(
22201            WorktreeProfileId::new("default").unwrap(),
22202            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22203            &allocation,
22204            "gate4agent",
22205            "HEAD",
22206            ManagedWorktreeRetention::Retain,
22207        ).unwrap();
22208        let workspace = WorkspaceConfig::new(
22209            WorkspaceId::new("primary").unwrap(),
22210            &source,
22211        ).unwrap()
22212            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22213            .with_managed_worktree_profile(profile.clone()).unwrap();
22214        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22215        let source_text = workspace.canonical_root().to_owned();
22216        let target_text = target.to_string_lossy().into_owned();
22217        let created = create_git_worktree(
22218            &source_text,
22219            &target_text,
22220            "gate4agent/mw-restart",
22221            Some("HEAD"),
22222        ).await.unwrap();
22223        let lease = ManagedWorktreeLeaseRecord {
22224            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22225            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22226            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22227            profile_id: profile.profile_id().clone(),
22228            profile_revision: profile.revision().clone(),
22229            target_root: created.path.clone(),
22230            branch: "gate4agent/mw-restart".to_owned(),
22231            base_commit: created.head.clone(),
22232            expected_head: None,
22233            retention: ManagedWorktreeRetention::Retain,
22234            state: ManagedWorktreeLeaseState::Allocating,
22235            session_holders: Vec::new(),
22236            record_holders: Vec::new(),
22237            cleanup_failure: None,
22238            created_at_unix_ms: 1,
22239            updated_at_unix_ms: 1,
22240        };
22241        let catalog = active_registry().unwrap();
22242        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22243        let shared = NodeShared::new_with_incarnation(
22244            handle,
22245            "fixture-token".to_owned(),
22246            NodeId::new("node-managed-restart").unwrap(),
22247            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22248            vec![workspace],
22249            vec![agent("claude")],
22250            ProviderRuntimeStatuses::default(),
22251            None,
22252            Vec::new(),
22253            Vec::new(),
22254            SpawnProfileRegistry::default(),
22255            None,
22256            None,
22257            None,
22258            None,
22259            Vec::new(),
22260            vec![lease.clone()],
22261            Vec::new(),
22262            Vec::new(),
22263            None,
22264            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22265        );
22266        shared.reconcile_managed_worktrees().await;
22267        let retained = shared.managed_worktrees.lock()
22268            .unwrap_or_else(|poisoned| poisoned.into_inner())
22269            .get(&lease.lease_id).unwrap().clone();
22270        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22271        assert!(shared.workspaces.read()
22272            .unwrap_or_else(|poisoned| poisoned.into_inner())
22273            .contains_key(&lease.workspace_id));
22274        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22275        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22276        assert!(!Path::new(&created.path).exists());
22277        std::fs::remove_dir_all(root).unwrap();
22278    }
22279
22280    fn run_test_git(root: &Path, arguments: &[&str]) {
22281        let output = std::process::Command::new("git")
22282            .arg("-C").arg(root).args(arguments).output().unwrap();
22283        assert!(
22284            output.status.success(),
22285            "git {:?} failed: {}",
22286            arguments,
22287            String::from_utf8_lossy(&output.stderr),
22288        );
22289    }
22290
22291    #[tokio::test]
22292    async fn spawn_spec_validation_fails_before_session_mutation() {
22293        let shared = terminal_test_shared();
22294        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22295            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22296        }));
22297        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22298            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22299        }));
22300        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22301            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22302        }));
22303
22304        let mut target_mismatch = spawn_spec_fixture();
22305        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22306        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22307        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22308
22309        let mut unknown_profile = spawn_spec_fixture();
22310        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22311        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22312        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22313        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22314
22315        let mut revision_mismatch = spawn_spec_fixture();
22316        revision_mismatch.idempotency_key =
22317            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22318        revision_mismatch.expected_profile_revision =
22319            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22320        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22321        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22322
22323        let mut unsupported_materializer = spawn_spec_fixture();
22324        unsupported_materializer.idempotency_key =
22325            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22326        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22327            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22328        };
22329        let failure = shared
22330            .spawn_from_spec(unsupported_materializer)
22331            .await
22332            .unwrap_err();
22333        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22334
22335        let failure = shared
22336            .spawn_session_with_deadline(
22337                WorkspaceId::new("primary").unwrap(),
22338                agent("claude"),
22339                SessionMode::Pty,
22340                gate4agent_types::TerminalSize {
22341                    rows: 24,
22342                    columns: 80,
22343                },
22344                None,
22345                None,
22346                None,
22347                None,
22348                None,
22349                None,
22350                SpawnRecordPolicy::ProviderIdentityOnly,
22351                Some(Instant::now()),
22352                &[],
22353                None,
22354                ApprovalLevel::default(),
22355                None,
22356                None,
22357            )
22358            .await
22359            .unwrap_err();
22360        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22361        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22362        assert!(shared.handle.snapshot().sessions.is_empty());
22363    }
22364
22365    #[test]
22366    fn worktree_selection_requires_its_separate_negotiated_capability() {
22367        let mut spec = spawn_spec_fixture();
22368        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22369        let request = NodeRequest::SpawnSpec { spec };
22370        let spawn_spec_capability =
22371            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22372        assert!(request_uses_unnegotiated_capability(
22373            &request,
22374            &[spawn_spec_capability.clone()],
22375        ));
22376        let worktree_capability =
22377            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22378        assert!(request_uses_unnegotiated_capability(
22379            &request,
22380            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22381        ));
22382        let profile_revision_capability =
22383            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22384        assert!(request_uses_unnegotiated_capability(
22385            &request,
22386            &[
22387                spawn_spec_capability.clone(),
22388                worktree_capability.clone(),
22389                profile_revision_capability.clone(),
22390            ],
22391        ));
22392        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22393        // `Inherit`, not `Clear` — the profile's own defaults might still
22394        // materialize a bundle or a context pack, so both capabilities stay
22395        // conservatively required until negotiated (see
22396        // `requires_session_bundle_materialization_capability` /
22397        // `requires_history_context_pack_capability`).
22398        let bundle_materialization_capability =
22399            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22400        assert!(request_uses_unnegotiated_capability(
22401            &request,
22402            &[
22403                spawn_spec_capability.clone(),
22404                worktree_capability.clone(),
22405                profile_revision_capability.clone(),
22406                bundle_materialization_capability.clone(),
22407            ],
22408        ));
22409        let history_context_pack_capability =
22410            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22411        assert!(!request_uses_unnegotiated_capability(
22412            &request,
22413            &[
22414                spawn_spec_capability,
22415                worktree_capability,
22416                profile_revision_capability,
22417                bundle_materialization_capability,
22418                history_context_pack_capability,
22419            ],
22420        ));
22421    }
22422
22423    #[tokio::test]
22424    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22425        let mut shared = terminal_test_shared();
22426        let spec = spawn_spec_fixture();
22427        let receipt = shared
22428            .resolve_spawn_spec(&spec)
22429            .unwrap()
22430            .receipt(shared.incarnation_id, terminal_address(1));
22431        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22432
22433        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22434        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22435        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22436
22437        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22438
22439        let mut revision_conflicting = spec.clone();
22440        revision_conflicting.expected_profile_revision =
22441            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22442        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22443        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22444
22445        let mut stale_cache_miss = spec.clone();
22446        stale_cache_miss.idempotency_key =
22447            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22448        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22449        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22450
22451        let mut conflicting = spec;
22452        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22453        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22454        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22455        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22456        assert!(shared.handle.snapshot().sessions.is_empty());
22457    }
22458
22459    #[tokio::test]
22460    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22461        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22462        let mut spec = spawn_spec_fixture();
22463        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22464        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22465        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22466
22467        let failure = shared
22468            .create_worktree(
22469                WorkspaceId::new("primary").unwrap(),
22470                WorkspaceId::new("new-worktree").unwrap(),
22471                "not-an-absolute-path".to_owned(),
22472                "invalid branch".to_owned(),
22473                None,
22474            )
22475            .await
22476            .unwrap_err();
22477        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22478
22479        let failure = shared
22480            .remove_worktree(
22481                WorkspaceId::new("primary").unwrap(),
22482                "not-an-absolute-path".to_owned(),
22483            )
22484            .await
22485            .unwrap_err();
22486        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22487        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22488        assert!(shared.handle.snapshot().sessions.is_empty());
22489    }
22490
22491    #[tokio::test]
22492    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22493        let root = temporary_workspace_root("manual-worktree-compensation");
22494        let source = root.join("source");
22495        let target = root.join("target");
22496        let invalid_state_parent = root.join("state-parent-is-a-file");
22497        let invalid_state_path = invalid_state_parent.join("state.json");
22498        std::fs::create_dir_all(&source).unwrap();
22499        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22500        run_test_git(&source, &["init"]);
22501        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22502        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22503        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22504        run_test_git(&source, &["add", "seed.txt"]);
22505        run_test_git(&source, &["commit", "-m", "seed"]);
22506
22507        let catalog = active_registry().unwrap();
22508        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22509        let workspace = WorkspaceConfig::new(
22510            WorkspaceId::new("primary").unwrap(),
22511            &source,
22512        ).unwrap();
22513        let mut shared = NodeShared::new(
22514            handle,
22515            "fixture-token".to_owned(),
22516            NodeId::new("node-worktree-compensation").unwrap(),
22517            vec![workspace],
22518            vec![agent("claude")],
22519        );
22520        shared.state_path = Some(invalid_state_path);
22521
22522        let error = shared.create_worktree(
22523            WorkspaceId::new("primary").unwrap(),
22524            WorkspaceId::new("compensated").unwrap(),
22525            target.to_string_lossy().into_owned(),
22526            "gate4agent/compensated".to_owned(),
22527            Some("HEAD".to_owned()),
22528        ).await.unwrap_err();
22529
22530        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22531        assert!(!target.exists());
22532        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22533            .iter()
22534            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22535        std::fs::remove_dir_all(root).unwrap();
22536    }
22537
22538    #[test]
22539    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22540        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22541            NativeGitWorktreeSnapshot {
22542                path: path.to_owned(),
22543                head: "0123456789abcdef".to_owned(),
22544                branch: Some("refs/heads/test".to_owned()),
22545                is_bare,
22546                is_main,
22547                locked: false,
22548                lock_reason: None,
22549                prunable: false,
22550                prunable_reason: None,
22551                workspace_id: None,
22552            }
22553        }
22554
22555        let source = r"C:\repo";
22556        let selected = r"C:\repo-worktree";
22557        let listed = vec![
22558            worktree(source, true, false),
22559            worktree(selected, false, false),
22560        ];
22561        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22562
22563        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22564        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22565
22566        let bare = vec![
22567            worktree(source, true, false),
22568            worktree(selected, false, true),
22569        ];
22570        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22571        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22572
22573        let foreign_failure =
22574            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22575        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22576    }
22577
22578    fn terminal_frame(sequence: u64) -> TerminalFrame {
22579        TerminalFrame {
22580            sequence,
22581            size: gate4agent_types::TerminalSize {
22582                rows: 24,
22583                columns: 80,
22584            },
22585            cursor_row: 2,
22586            cursor_column: 3,
22587            contents: format!("frame-{sequence}"),
22588            formatted: vec![1, 2, 3],
22589            scrollback_formatted: Vec::new(),
22590            alternate_screen: false,
22591            mouse_protocol_enabled: false,
22592            mouse_protocol_encoding: Default::default(),
22593            produced_at_unix_ms: 0,
22594            screen_state: gate4agent_types::PtyScreenState::default(),
22595            bracketed_paste: None,
22596        }
22597    }
22598
22599    #[test]
22600    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22601        let shared = terminal_test_shared();
22602        let mut receiver = shared.terminal_event_tx.subscribe();
22603        let original = terminal_address(1);
22604
22605        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22606        let first = receiver.try_recv().unwrap();
22607        assert_eq!(first.len(), 1);
22608        assert!(matches!(
22609            &first[0].event,
22610            NodeEvent::TerminalFrame { address, frame }
22611                if address == &original && frame.sequence == 7
22612        ));
22613
22614        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22615        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22616        assert!(matches!(
22617            receiver.try_recv(),
22618            Err(broadcast::error::TryRecvError::Empty)
22619        ));
22620
22621        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22622        let advanced = receiver.try_recv().unwrap();
22623        assert_eq!(advanced[0].sequence, 2);
22624
22625        let resumed = terminal_address(2);
22626        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22627        let reset = receiver.try_recv().unwrap();
22628        assert_eq!(reset[0].sequence, 3);
22629        assert!(matches!(
22630            &reset[0].event,
22631            NodeEvent::TerminalFrame { address, frame }
22632                if address == &resumed && frame.sequence == 1
22633        ));
22634        shared.clear_terminal_frame_watermark(&resumed);
22635        assert!(!shared
22636            .terminal_frame_watermarks
22637            .lock()
22638            .unwrap_or_else(|poisoned| poisoned.into_inner())
22639            .contains_key(&resumed.session.instance_id));
22640    }
22641
22642    #[test]
22643    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22644        let shared = terminal_test_shared();
22645        let mut receiver = shared.terminal_event_tx.subscribe();
22646        let address = terminal_address(1);
22647        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22648
22649        for sequence in 1..=count {
22650            shared.publish_terminal_frame_candidates(vec![(
22651                address.clone(),
22652                terminal_frame(sequence),
22653            )]);
22654        }
22655
22656        let history = shared
22657            .history
22658            .lock()
22659            .unwrap_or_else(|poisoned| poisoned.into_inner());
22660        assert!(history.events.is_empty());
22661        assert_eq!(history.last_sequence, count);
22662        assert_eq!(history.replay_floor_sequence, 1);
22663        drop(history);
22664        let NodeResponse::Resync {
22665            event_sequence,
22666            oldest_available_sequence,
22667            events,
22668            ..
22669        } = shared.resync(0) else {
22670            unreachable!("resync helper returned another response")
22671        };
22672        assert_eq!(event_sequence, count);
22673        assert_eq!(oldest_available_sequence, 1);
22674        assert!(events.is_empty());
22675        assert!(matches!(
22676            resync_required_event(&shared).event,
22677            NodeEvent::ResyncRequired {
22678                oldest_available_sequence: 1,
22679            }
22680        ));
22681        assert!(matches!(
22682            receiver.try_recv(),
22683            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22684        ));
22685        let latest = receiver.try_recv().unwrap();
22686        assert_eq!(latest.len(), 1);
22687        assert_eq!(latest[0].sequence, count);
22688    }
22689
22690    #[test]
22691    fn real_durable_eviction_advances_authoritative_replay_floor() {
22692        let shared = terminal_test_shared();
22693        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22694            shared.publish(NodeEvent::ControllerChanged { controller: None });
22695        }
22696
22697        let NodeResponse::Resync {
22698            event_sequence,
22699            oldest_available_sequence,
22700            events,
22701            ..
22702        } = shared.resync(0) else {
22703            unreachable!("resync helper returned another response")
22704        };
22705        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22706        assert_eq!(oldest_available_sequence, 2);
22707        assert_eq!(events.first().map(|event| event.sequence), Some(2));
22708        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22709        assert!(matches!(
22710            resync_required_event(&shared).event,
22711            NodeEvent::ResyncRequired {
22712                oldest_available_sequence: 2,
22713            }
22714        ));
22715    }
22716
22717    #[test]
22718    fn legacy_terminal_frame_connection_has_no_live_subscription() {
22719        let shared = terminal_test_shared();
22720        let without_capability = terminal_event_subscription(&shared, false);
22721        let with_capability = terminal_event_subscription(&shared, true);
22722        assert!(without_capability.is_none());
22723        assert!(with_capability.is_some());
22724        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22725            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22726        }));
22727    }
22728
22729    #[test]
22730    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22731        let mut pending = Vec::new();
22732        let mut event_sequence = 0_u64;
22733        for frame_sequence in 1..=4 {
22734            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22735                event_sequence += 1;
22736                let mut address = terminal_address(1);
22737                address.session.instance_id = AgentInstanceId(instance);
22738                queue_connection_event(
22739                    &mut pending,
22740                    NodeEventEnvelope {
22741                        sequence: event_sequence,
22742                        event: NodeEvent::TerminalFrame {
22743                            address,
22744                            frame: terminal_frame(frame_sequence),
22745                        },
22746                    },
22747                    0,
22748                );
22749            }
22750        }
22751        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22752        pending.sort_unstable_by_key(|event| event.sequence);
22753
22754        let mut uninterrupted = pending.clone();
22755        let first_burst = connection_event_burst_len(uninterrupted.len());
22756        uninterrupted.drain(..first_burst);
22757        let second_burst = connection_event_burst_len(uninterrupted.len());
22758        uninterrupted.drain(..second_burst);
22759        assert!(uninterrupted.is_empty());
22760
22761        let (frame_tx, mut frame_rx) =
22762            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22763        frame_tx
22764            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22765                request_id: 77,
22766                request: NodeRequest::Snapshot,
22767            })))
22768            .unwrap();
22769        let burst_len = connection_event_burst_len(pending.len());
22770        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22771        pending.drain(..burst_len);
22772        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22773        assert!(matches!(
22774            frame_rx.try_recv(),
22775            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22776                request_id: 77,
22777                request: NodeRequest::Snapshot,
22778            })))
22779        ));
22780    }
22781
22782    #[test]
22783    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22784        let mut pending = Vec::new();
22785        for sequence in [7_u64, 8] {
22786            let mut address = terminal_address(1);
22787            address.session.instance_id = AgentInstanceId(sequence);
22788            queue_connection_event(
22789                &mut pending,
22790                NodeEventEnvelope {
22791                    sequence,
22792                    event: NodeEvent::TerminalFrame {
22793                        address,
22794                        frame: terminal_frame(sequence),
22795                    },
22796                },
22797                0,
22798            );
22799        }
22800        let mut discard_through = 0;
22801        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22802        assert!(pending.is_empty());
22803
22804        let replayed = NodeEventEnvelope {
22805            sequence: 8,
22806            event: NodeEvent::TerminalFrame {
22807                address: terminal_address(1),
22808                frame: terminal_frame(8),
22809            },
22810        };
22811        let advanced = NodeEventEnvelope {
22812            sequence: 9,
22813            event: NodeEvent::TerminalFrame {
22814                address: terminal_address(2),
22815                frame: terminal_frame(1),
22816            },
22817        };
22818        queue_connection_event_batch(
22819            &mut pending,
22820            &[replayed, advanced.clone()],
22821            discard_through,
22822        );
22823        assert_eq!(pending, vec![advanced]);
22824    }
22825
22826    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22827        ManagedSessionRecord {
22828            record_id: SessionRecordId::new(record_id).unwrap(),
22829            display_name: format!("{provider} record"),
22830            provider: agent(provider),
22831            mode: SessionMode::Pty,
22832            state: ManagedSessionState::Unavailable,
22833            workspace_id: WorkspaceId::new("primary").unwrap(),
22834            canonical_root: opaque_windows_path(
22835                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22836            ),
22837            provider_session: None,
22838            active_session: None,
22839            environment_profile: None,
22840            bundle: None,
22841            context_id: None,
22842            context: None,
22843            exported_context: None,
22844            task_binding: None,
22845            created_at_unix_ms: 1,
22846            updated_at_unix_ms: 1,
22847            last_error: None,
22848        }
22849    }
22850
22851    fn retention_test_record(
22852        workspace_id: &str,
22853        record_id: &str,
22854        state: ManagedSessionState,
22855        updated_at_unix_ms: u64,
22856    ) -> ManagedSessionRecord {
22857        ManagedSessionRecord {
22858            record_id: SessionRecordId::new(record_id).unwrap(),
22859            display_name: format!("{record_id} display"),
22860            provider: agent("claude"),
22861            mode: SessionMode::Pty,
22862            state,
22863            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22864            canonical_root: opaque_windows_path(
22865                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22866            ),
22867            provider_session: None,
22868            active_session: None,
22869            environment_profile: None,
22870            bundle: None,
22871            context_id: None,
22872            context: None,
22873            exported_context: None,
22874            task_binding: None,
22875            created_at_unix_ms: updated_at_unix_ms,
22876            updated_at_unix_ms,
22877            last_error: None,
22878        }
22879    }
22880
22881    /// Two workspaces of three `Unavailable` records each, staggered by
22882    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22883    /// `workspace-a` (all as old as that workspace's oldest real record --
22884    /// if the sweep's state gate ever leaked, these would be the first
22885    /// thing it wrongly retired). Ages, oldest to newest:
22886    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
22887    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
22888    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22889        vec![
22890            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22891            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22892            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22893            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22894            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22895            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22896            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22897            retention_test_record(
22898                "workspace-a",
22899                "decoy-pending",
22900                ManagedSessionState::IdentityPending,
22901                now - 500_000,
22902            ),
22903            retention_test_record(
22904                "workspace-a",
22905                "decoy-dormant",
22906                ManagedSessionState::Dormant,
22907                now - 500_000,
22908            ),
22909        ]
22910    }
22911
22912    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22913        let root = temporary_workspace_root("session-record-retention");
22914        std::fs::create_dir_all(&root).unwrap();
22915        let catalog = active_registry().unwrap();
22916        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22917        let workspace_a =
22918            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22919        let workspace_b =
22920            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22921        let mut shared = NodeShared::new(
22922            handle,
22923            "fixture-token".to_owned(),
22924            NodeId::new("node-retention-test").unwrap(),
22925            vec![workspace_a, workspace_b],
22926            vec![agent("claude")],
22927        );
22928        shared.state_path = Some(root.join("node-state-v10.json"));
22929        let now = unix_time_ms();
22930        for record in retention_fixture_records(now) {
22931            shared.insert_record(record).unwrap();
22932        }
22933        shared
22934            .persist_state_locked()
22935            .expect("fixture-only, empty-history persist against a fresh temp state path");
22936        (shared, runtime, root, now)
22937    }
22938
22939    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22940        shared
22941            .history
22942            .lock()
22943            .unwrap_or_else(|poisoned| poisoned.into_inner())
22944            .events
22945            .iter()
22946            .filter_map(|envelope| match &envelope.event {
22947                NodeEvent::SessionRecordRemoved { record_id } => {
22948                    Some(record_id.as_str().to_owned())
22949                }
22950                _ => None,
22951            })
22952            .collect()
22953    }
22954
22955    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22956        shared
22957            .session_records
22958            .lock()
22959            .unwrap_or_else(|poisoned| poisoned.into_inner())
22960            .records
22961            .keys()
22962            .map(|record_id| record_id.as_str().to_owned())
22963            .collect()
22964    }
22965
22966    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22967        values.iter().map(|value| (*value).to_owned()).collect()
22968    }
22969
22970    #[tokio::test]
22971    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22972        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22973        shared.session_record_retention = SessionRecordRetentionConfig {
22974            age_ms: 0,
22975            keep_per_workspace: 1,
22976        };
22977        shared.retire_unavailable_session_records().await;
22978
22979        assert_eq!(
22980            retention_remaining_record_ids(&shared),
22981            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22982        );
22983        assert_eq!(
22984            retention_removed_record_ids(&shared),
22985            owned(&["a1", "a2", "b1", "b2"]),
22986        );
22987        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22988
22989        drop(shared);
22990        drop(runtime);
22991        std::fs::remove_dir_all(root).unwrap();
22992    }
22993
22994    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
22995    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
22996    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
22997    /// 200s old against a 250s threshold) even though it fails the keep-N
22998    /// test too, proving retirement needs BOTH, not either.
22999    #[tokio::test]
23000    async fn session_record_retention_both_set_retires_only_the_intersection() {
23001        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23002        shared.session_record_retention = SessionRecordRetentionConfig {
23003            age_ms: 250_000,
23004            keep_per_workspace: 1,
23005        };
23006        shared.retire_unavailable_session_records().await;
23007
23008        assert_eq!(
23009            retention_remaining_record_ids(&shared),
23010            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23011        );
23012        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23013        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23014
23015        drop(shared);
23016        drop(runtime);
23017        std::fs::remove_dir_all(root).unwrap();
23018    }
23019
23020    #[tokio::test]
23021    async fn session_record_retention_both_disabled_is_a_no_op() {
23022        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23023        shared.session_record_retention = SessionRecordRetentionConfig::default();
23024        shared.retire_unavailable_session_records().await;
23025
23026        assert_eq!(
23027            retention_remaining_record_ids(&shared),
23028            owned(&[
23029                "a1", "a2", "a3", "b1", "b2", "b3",
23030                "decoy-live", "decoy-pending", "decoy-dormant",
23031            ]),
23032        );
23033        assert!(retention_removed_record_ids(&shared).is_empty());
23034        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23035
23036        drop(shared);
23037        drop(runtime);
23038        std::fs::remove_dir_all(root).unwrap();
23039    }
23040
23041    #[test]
23042    fn session_record_context_export_selects_one_exact_history_identity() {
23043        let candidates = vec![
23044            HistoryCandidateSummary {
23045                id: "candidate-other".to_owned(),
23046                session_id_hint: "provider-other".to_owned(),
23047                modified_at_unix_ms: Some(1),
23048            },
23049            HistoryCandidateSummary {
23050                id: "candidate-exact".to_owned(),
23051                session_id_hint: "provider-exact".to_owned(),
23052                modified_at_unix_ms: Some(2),
23053            },
23054        ];
23055        assert_eq!(
23056            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23057            "candidate-exact",
23058        );
23059    }
23060
23061    #[test]
23062    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23063        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23064            id: id.to_owned(),
23065            session_id_hint: hint.to_owned(),
23066            modified_at_unix_ms: None,
23067        };
23068        let missing = select_history_candidate(
23069            &[candidate("candidate-other", "provider-other")],
23070            Some("provider-exact"),
23071        )
23072        .unwrap_err();
23073        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23074        let ambiguous = select_history_candidate(
23075            &[
23076                candidate("candidate-a", "provider-exact"),
23077                candidate("candidate-b", "provider-exact"),
23078            ],
23079            Some("provider-exact"),
23080        )
23081        .unwrap_err();
23082        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23083    }
23084
23085    /// The PTY-sidecar-observation shape (no fleet provider today, but
23086    /// capability-keyed rather than vendor-locked) never carries a verified
23087    /// `provider_session`; its record-bound export instead falls back to
23088    /// "exactly one discovered candidate" (see
23089    /// `export_context_pack_for_session_record_inner`). This is the same
23090    /// ambiguity refusal as the identity-filtered path, just without an
23091    /// identity to filter by.
23092    #[test]
23093    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23094        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23095            id: id.to_owned(),
23096            session_id_hint: hint.to_owned(),
23097            modified_at_unix_ms: None,
23098        };
23099        assert_eq!(
23100            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23101                .unwrap(),
23102            "candidate-sole",
23103        );
23104        let missing = select_history_candidate(&[], None).unwrap_err();
23105        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23106        let ambiguous = select_history_candidate(
23107            &[
23108                candidate("candidate-a", "sidecar-session-a"),
23109                candidate("candidate-b", "sidecar-session-b"),
23110            ],
23111            None,
23112        )
23113        .unwrap_err();
23114        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23115    }
23116
23117    #[test]
23118    fn session_record_context_export_revalidation_rejects_record_change() {
23119        let session = terminal_address(1);
23120        let identity = ProviderSessionIdentity {
23121            key: ProviderSessionKey::SessionId,
23122            id: "provider-exact".to_owned(),
23123            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23124        };
23125        let mut expected = record("claude", "record-context-export");
23126        expected.state = ManagedSessionState::Live;
23127        expected.provider_session = Some(identity.clone());
23128        expected.active_session = Some(session.clone());
23129        let binding = SessionBinding {
23130            workspace_id: session.workspace_id.clone(),
23131            generation: session.session.generation,
23132            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23133            pending_resume: None,
23134            record_id: Some(expected.record_id.clone()),
23135            managed_worktree_lease_id: None,
23136            environment_profile: None,
23137            bundle: None,
23138            context: None,
23139            materialization_id: None,
23140        };
23141        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23142        assert!(session_record_context_export_binding_is_exact(
23143            &expected,
23144            &expected,
23145            Some(&identity),
23146            &session,
23147            &binding,
23148            &expected.provider,
23149            &current_root,
23150            false,
23151        ));
23152        let mut changed = expected.clone();
23153        changed.updated_at_unix_ms += 1;
23154        assert!(!session_record_context_export_binding_is_exact(
23155            &expected,
23156            &changed,
23157            Some(&identity),
23158            &session,
23159            &binding,
23160            &expected.provider,
23161            &current_root,
23162            false,
23163        ));
23164        // A same-instant timestamp bump alone (state and active_session
23165        // otherwise unchanged) is not the clean-detachment transition
23166        // either, even with the reactive flag on: only the exact Live ->
23167        // Dormant + active_session cleared shape qualifies.
23168        assert!(!session_record_context_export_binding_is_exact(
23169            &expected,
23170            &changed,
23171            Some(&identity),
23172            &session,
23173            &binding,
23174            &expected.provider,
23175            &current_root,
23176            true,
23177        ));
23178        let mut detached = expected.clone();
23179        detached.state = ManagedSessionState::Dormant;
23180        detached.active_session = None;
23181        detached.updated_at_unix_ms += 1;
23182        assert!(!session_record_context_export_binding_is_exact(
23183            &expected,
23184            &detached,
23185            Some(&identity),
23186            &session,
23187            &binding,
23188            &expected.provider,
23189            &current_root,
23190            false,
23191        ));
23192        assert!(session_record_context_export_binding_is_exact(
23193            &expected,
23194            &detached,
23195            Some(&identity),
23196            &session,
23197            &binding,
23198            &expected.provider,
23199            &current_root,
23200            true,
23201        ));
23202    }
23203
23204    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23205    /// for a record whose provider never carries a verified identity (the
23206    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23207    /// declares one today): `identity` is `None` throughout, and the
23208    /// exactness check falls back to comparing `provider_session` itself,
23209    /// which must still be `None` on both sides.
23210    #[test]
23211    fn session_record_context_export_binding_is_exact_without_identity() {
23212        let session = terminal_address(1);
23213        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23214        expected.state = ManagedSessionState::Live;
23215        expected.active_session = Some(session.clone());
23216        let binding = SessionBinding {
23217            workspace_id: session.workspace_id.clone(),
23218            generation: session.session.generation,
23219            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23220            pending_resume: None,
23221            record_id: Some(expected.record_id.clone()),
23222            managed_worktree_lease_id: None,
23223            environment_profile: None,
23224            bundle: None,
23225            context: None,
23226            materialization_id: None,
23227        };
23228        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23229        assert!(session_record_context_export_binding_is_exact(
23230            &expected,
23231            &expected,
23232            None,
23233            &session,
23234            &binding,
23235            &expected.provider,
23236            &current_root,
23237            false,
23238        ));
23239        // A record that gained an identity between the initial lookup and
23240        // this recheck no longer matches the `None` this shape expects to
23241        // keep seeing -- treated the same as any other mid-export drift.
23242        let mut drifted = expected.clone();
23243        drifted.provider_session = Some(ProviderSessionIdentity {
23244            key: ProviderSessionKey::SessionId,
23245            id: "unexpected-identity".to_owned(),
23246            transcript_path: None,
23247        });
23248        assert!(!session_record_context_export_binding_is_exact(
23249            &expected,
23250            &drifted,
23251            None,
23252            &session,
23253            &binding,
23254            &expected.provider,
23255            &current_root,
23256            false,
23257        ));
23258    }
23259
23260    #[test]
23261    fn session_record_context_export_preflight_requires_live_usable_source() {
23262        let mut source = record("claude", "record-context-export-preflight");
23263        source.state = ManagedSessionState::Live;
23264        assert!(session_record_context_export_source_is_usable(
23265            &source,
23266            &SessionStatus::Running,
23267            false,
23268        ));
23269        assert!(session_record_context_export_source_is_usable(
23270            &source,
23271            &SessionStatus::Exited { exit_code: Some(0) },
23272            false,
23273        ));
23274
23275        source.state = ManagedSessionState::Dormant;
23276        assert!(!session_record_context_export_source_is_usable(
23277            &source,
23278            &SessionStatus::Running,
23279            false,
23280        ));
23281        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23282        // the only caller that may treat a Dormant, cleanly-exited record as
23283        // still usable — the on-demand path above never does.
23284        assert!(session_record_context_export_source_is_usable(
23285            &source,
23286            &SessionStatus::Exited { exit_code: Some(0) },
23287            true,
23288        ));
23289        assert!(!session_record_context_export_source_is_usable(
23290            &source,
23291            &SessionStatus::Exited { exit_code: Some(0) },
23292            false,
23293        ));
23294        source.state = ManagedSessionState::Live;
23295        assert!(!session_record_context_export_source_is_usable(
23296            &source,
23297            &SessionStatus::Starting,
23298            false,
23299        ));
23300    }
23301
23302    #[test]
23303    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23304        let catalog = active_registry().unwrap();
23305        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23306        let workspace = WorkspaceConfig::new(
23307            WorkspaceId::new("primary").unwrap(),
23308            std::env::current_dir().unwrap(),
23309        )
23310        .unwrap();
23311        let shared = NodeShared::new(
23312            handle,
23313            "fixture-token".to_owned(),
23314            NodeId::new("node-context-export-conflict").unwrap(),
23315            vec![workspace],
23316            vec![agent("claude")],
23317        );
23318        let history = HistorySessionRecord {
23319            session_id: "provider-exact".to_owned(),
23320            title: None,
23321            cwd: None,
23322            model: None,
23323            message_count: 1,
23324            completed_turn_count: None,
23325            total_tokens: 0,
23326            messages: vec![gate4agent_types::HistoryMessageRecord {
23327                role: gate4agent_types::HistoryMessageRole::User,
23328                text: "bounded context".to_owned(),
23329            }],
23330        };
23331        let pack = NodeContextPack::export(
23332            ContextPackLineageReceipt {
23333                source_node_id: shared.node_id.clone(),
23334                source_session: terminal_address(1),
23335                source_provider: agent("claude"),
23336            },
23337            &history,
23338        )
23339        .unwrap();
23340        let context_id = pack.receipt().id.clone();
23341        let session = terminal_address(1);
23342        let identity = ProviderSessionIdentity {
23343            key: ProviderSessionKey::SessionId,
23344            id: history.session_id.clone(),
23345            transcript_path: None,
23346        };
23347        let mut expected = record("claude", "record-context-export-final-conflict");
23348        expected.state = ManagedSessionState::Live;
23349        expected.provider_session = Some(identity.clone());
23350        expected.active_session = Some(session.clone());
23351        let mut changed = expected.clone();
23352        changed.updated_at_unix_ms += 1;
23353        shared.insert_record(changed).unwrap();
23354
23355        let error = shared
23356            .commit_context_pack_for_session_record(
23357                &expected,
23358                Some(&identity),
23359                &session,
23360                "candidate-exact",
23361                &history,
23362                pack,
23363                false,
23364            )
23365            .unwrap_err();
23366
23367        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23368        assert!(shared
23369            .context_catalog
23370            .read()
23371            .unwrap_or_else(|poisoned| poisoned.into_inner())
23372            .get(&context_id)
23373            .is_none());
23374    }
23375
23376    #[tokio::test]
23377    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23378        let root = temporary_workspace_root("session-task-binding");
23379        std::fs::create_dir_all(&root).unwrap();
23380        let state_path = root.join("node-state.json");
23381        let catalog = active_registry().unwrap();
23382        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23383        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23384        let node_id = NodeId::new("node-1").unwrap();
23385        let mut shared = NodeShared::new(
23386            handle,
23387            "fixture-token".to_owned(),
23388            node_id.clone(),
23389            vec![workspace],
23390            vec![agent("claude")],
23391        );
23392        shared.state_path = Some(state_path.clone());
23393        let record = record("claude", "sr-task");
23394        let record_id = record.record_id.clone();
23395        shared.insert_record(record).unwrap();
23396        shared.persist_state().unwrap();
23397        shared
23398            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23399            .unwrap();
23400
23401        let minted = process_request_inner(
23402            &shared,
23403            77,
23404            ClientRole::Operator,
23405            NodeRequest::SetSessionTask {
23406                record_id: record_id.clone(),
23407                expected_revision: 0,
23408                target: SessionTaskTargetV1::New,
23409            },
23410        )
23411        .await
23412        .unwrap();
23413        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23414            panic!("session task mutation returned another response");
23415        };
23416        let minted_binding = minted.task_binding.clone().unwrap();
23417        let minted_task_id = minted_binding.task_id.clone().unwrap();
23418        assert_eq!(minted_binding.revision, 1);
23419        assert!(minted_task_id.as_str().starts_with("task-"));
23420        assert_eq!(minted_task_id.as_str().len(), 29);
23421
23422        let event_count = shared.current_sequence();
23423        let repeated = process_request_inner(
23424            &shared,
23425            77,
23426            ClientRole::Operator,
23427            NodeRequest::SetSessionTask {
23428                record_id: record_id.clone(),
23429                expected_revision: 1,
23430                target: SessionTaskTargetV1::Existing {
23431                    task_id: minted_task_id.clone(),
23432                },
23433            },
23434        )
23435        .await
23436        .unwrap();
23437        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23438        assert_eq!(shared.current_sequence(), event_count);
23439
23440        let stale = process_request_inner(
23441            &shared,
23442            77,
23443            ClientRole::Operator,
23444            NodeRequest::SetSessionTask {
23445                record_id: record_id.clone(),
23446                expected_revision: 0,
23447                target: SessionTaskTargetV1::Existing {
23448                    task_id: minted_task_id,
23449                },
23450            },
23451        )
23452        .await
23453        .unwrap_err();
23454        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23455
23456        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23457        let assigned = process_request_inner(
23458            &shared,
23459            77,
23460            ClientRole::Operator,
23461            NodeRequest::SetSessionTask {
23462                record_id: record_id.clone(),
23463                expected_revision: 1,
23464                target: SessionTaskTargetV1::Existing {
23465                    task_id: assigned_id.clone(),
23466                },
23467            },
23468        )
23469        .await
23470        .unwrap();
23471        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23472            panic!("session task assignment returned another response");
23473        };
23474        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23475        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23476
23477        let cleared = process_request_inner(
23478            &shared,
23479            77,
23480            ClientRole::Operator,
23481            NodeRequest::SetSessionTask {
23482                record_id: record_id.clone(),
23483                expected_revision: 2,
23484                target: SessionTaskTargetV1::Clear,
23485            },
23486        )
23487        .await
23488        .unwrap();
23489        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23490            panic!("session task clear returned another response");
23491        };
23492        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23493        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23494        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23495        assert_eq!(
23496            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23497            cleared.updated_at_unix_ms,
23498        );
23499
23500        let persisted = std::fs::read(&state_path).unwrap();
23501        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23502        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23503        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23504        std::fs::remove_dir_all(root).unwrap();
23505    }
23506
23507    #[test]
23508    fn session_task_capability_strips_snapshots_events_and_replies() {
23509        let mut bound = record("claude", "sr-task-projection");
23510        bound.updated_at_unix_ms = 2;
23511        bound.task_binding = Some(SessionTaskBindingV1 {
23512            revision: 1,
23513            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23514            changed_at_unix_ms: 2,
23515        });
23516        let request = NodeRequest::SetSessionTask {
23517            record_id: bound.record_id.clone(),
23518            expected_revision: 1,
23519            target: SessionTaskTargetV1::Clear,
23520        };
23521        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23522            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23523        }));
23524        assert!(!request_uses_unnegotiated_capability(
23525            &request,
23526            &baseline_capabilities().unwrap(),
23527        ));
23528        assert!(request_uses_unnegotiated_capability(&request, &[]));
23529
23530        let mut snapshot = terminal_test_shared().snapshot();
23531        snapshot.session_records.push(bound.clone());
23532        let mut reply = ResponseEnvelope {
23533            request_id: 1,
23534            result: Ok(NodeResponse::Resync {
23535                event_sequence: 1,
23536                oldest_available_sequence: 1,
23537                snapshot,
23538                events: vec![NodeEventEnvelope {
23539                    sequence: 1,
23540                    event: NodeEvent::SessionRecordUpserted {
23541                        record: bound.clone(),
23542                    },
23543                }],
23544            }),
23545        };
23546        project_response_without_session_task_binding(&mut reply);
23547        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23548            panic!("task projection changed the response kind");
23549        };
23550        assert!(snapshot.session_records[0].task_binding.is_none());
23551        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23552            panic!("task projection changed the event kind");
23553        };
23554        assert!(record.task_binding.is_none());
23555
23556        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23557            sequence: 2,
23558            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23559        });
23560        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23561            panic!("task event projection changed the event kind");
23562        };
23563        assert!(record.task_binding.is_none());
23564
23565        let mut direct = ResponseEnvelope {
23566            request_id: 2,
23567            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23568        };
23569        project_response_without_session_task_binding(&mut direct);
23570        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23571            panic!("task reply projection changed the response kind");
23572        };
23573        assert!(record.task_binding.is_none());
23574    }
23575
23576    #[test]
23577    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23578        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23579        let workspace = WorkspaceConfig::new(
23580            WorkspaceId::new("primary").unwrap(),
23581            std::env::current_dir().unwrap(),
23582        )
23583        .unwrap();
23584        let config = NodeServerConfig::new(
23585            DEFAULT_NODE_ENDPOINT,
23586            "fixture-token",
23587            NodeId::new("node-1").unwrap(),
23588            [workspace],
23589        )
23590        .unwrap();
23591        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23592    }
23593
23594    #[cfg(all(feature = "fixture", windows))]
23595    #[test]
23596    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23597        let root = temporary_workspace_root("provider-bundle-argv-hold");
23598        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23599        std::fs::create_dir_all(&root).unwrap();
23600        std::fs::create_dir_all(&outside).unwrap();
23601        let proof_path = root.join("provider.proof");
23602        let release_signal = root.join("release.signal");
23603        let config = NodeServerConfig::new(
23604            format!(
23605                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23606                std::process::id(),
23607            ),
23608            "fixture-token",
23609            NodeId::new("provider-bundle-hold-node").unwrap(),
23610            [WorkspaceConfig::new(
23611                WorkspaceId::new("primary").unwrap(),
23612                std::env::current_dir().unwrap(),
23613            )
23614            .unwrap()],
23615        )
23616        .unwrap()
23617        .with_spawn_profiles(SpawnProfileRegistry::new([
23618            crate::protocol::SpawnProfileDefaults {
23619                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23620                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23621                    .unwrap(),
23622                provider: agent("kimi"),
23623                mode: SessionMode::Pty,
23624                terminal_size: gate4agent_types::TerminalSize {
23625                    rows: 24,
23626                    columns: 80,
23627                },
23628                prompt: None,
23629                bundle_id: None,
23630                context_id: None,
23631                environment_profile_id: None,
23632            },
23633        ])
23634        .unwrap());
23635        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23636            config,
23637            agent("kimi"),
23638            proof_path.clone(),
23639            release_signal.clone(),
23640        )
23641        .unwrap();
23642        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23643
23644        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23645            agent("kimi"),
23646            proof_path.clone(),
23647            Some(release_signal.clone()),
23648        )
23649        .unwrap();
23650        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23651        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23652        let release_wait = wrapper
23653            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23654            .unwrap();
23655        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23656        assert!(proof_write < release_wait);
23657        assert!(release_wait < fixture_ready);
23658        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23659        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23660        assert_eq!(
23661            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23662            &[
23663                proof_path.to_string_lossy().into_owned(),
23664                release_signal.to_string_lossy().into_owned(),
23665            ],
23666        );
23667
23668        let error = NodeServer::provider_bundle_argv_fixture_spec(
23669            agent("kimi"),
23670            proof_path,
23671            Some(outside.join("release.signal")),
23672        )
23673        .unwrap_err();
23674        assert!(matches!(
23675            error,
23676            NodeServerError::Registry(message)
23677                if message == "provider bundle argv release path must share the proof parent"
23678        ));
23679        drop(server);
23680        std::fs::remove_dir_all(root).unwrap();
23681        std::fs::remove_dir_all(outside).unwrap();
23682    }
23683
23684    #[cfg(feature = "fixture")]
23685    #[test]
23686    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23687        let root = temporary_workspace_root("context-fixture-registry");
23688        std::fs::create_dir_all(&root).unwrap();
23689        let proof_path = root.join("context.proof");
23690        let config = NodeServerConfig::new(
23691            format!(
23692                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23693                std::process::id(),
23694            ),
23695            "fixture-token",
23696            NodeId::new("context-fixture-node").unwrap(),
23697            [WorkspaceConfig::new(
23698                WorkspaceId::new("primary").unwrap(),
23699                std::env::current_dir().unwrap(),
23700            )
23701            .unwrap()],
23702        )
23703        .unwrap();
23704        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23705        let mut enabled = server
23706            .shared
23707            .enabled_providers
23708            .iter()
23709            .map(AgentId::as_str)
23710            .collect::<Vec<_>>();
23711        enabled.sort_unstable();
23712        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23713        for provider in &server.shared.enabled_providers {
23714            assert!(server
23715                .shared
23716                .provider_adapter_contracts
23717                .iter()
23718                .any(|contract| {
23719                    &contract.provider == provider
23720                        && contract.family == AdapterFamily::History
23721                }));
23722        }
23723        drop(server);
23724        std::fs::remove_dir_all(root).unwrap();
23725    }
23726
23727    #[cfg(feature = "fixture")]
23728    #[test]
23729    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23730        let root = temporary_workspace_root("monitoring-context-fixture");
23731        std::fs::create_dir_all(&root).unwrap();
23732        let proof_path = root.join("context.proof");
23733        let standard = NodeServer::context_pack_fixture_catalog(
23734            proof_path.clone(),
23735            None,
23736            false,
23737        ).unwrap();
23738        let monitoring = NodeServer::context_pack_fixture_catalog(
23739            proof_path.clone(),
23740            None,
23741            true,
23742        ).unwrap();
23743        let claude = AgentId::new("claude").unwrap();
23744        let standard_claude = standard.get(&claude).unwrap();
23745        let monitoring_claude = monitoring.get(&claude).unwrap();
23746        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23747        // standard nor the monitoring fixture declares a hook adapter
23748        // anymore.
23749        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23750        assert_eq!(
23751            monitoring_claude.capabilities.adapters.history,
23752            standard_claude.capabilities.adapters.history,
23753        );
23754        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23755        let codex = AgentId::new("codex").unwrap();
23756        let standard_codex = standard.get(&codex).unwrap();
23757        let monitoring_codex = monitoring.get(&codex).unwrap();
23758        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23759        assert_eq!(
23760            monitoring_codex.capabilities.adapters.history,
23761            standard_codex.capabilities.adapters.history,
23762        );
23763        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23764        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23765            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23766            .unwrap();
23767        for required in [
23768            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23769            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23770            "@('role', 'text')",
23771            "g4a-private-provider-session-canary",
23772            "private-provider-login-identity",
23773            "$rawProof",
23774            "$env:GATE4AGENT_HOOK_URL",
23775            "g4a-private-provider-session-canary",
23776        ] {
23777            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23778        }
23779        for provider in ["grok", "kimi"] {
23780            let provider = AgentId::new(provider).unwrap();
23781            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23782        }
23783
23784        let config = NodeServerConfig::new(
23785            format!(
23786                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23787                std::process::id(),
23788            ),
23789            "fixture-token",
23790            NodeId::new("monitoring-context-fixture-node").unwrap(),
23791            [WorkspaceConfig::new(
23792                WorkspaceId::new("primary").unwrap(),
23793                std::env::current_dir().unwrap(),
23794            )
23795            .unwrap()],
23796        )
23797        .unwrap();
23798        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23799        assert!(server.shared.fixture_semantic_hook_policy);
23800        drop(server);
23801        std::fs::remove_dir_all(root).unwrap();
23802    }
23803
23804    #[cfg(feature = "fixture")]
23805    #[test]
23806    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23807        let root = temporary_workspace_root("kimi-context-only-fixture");
23808        std::fs::create_dir_all(&root).unwrap();
23809        let proof_path = root.join("context.proof");
23810        let standard = NodeServer::context_pack_fixture_catalog(
23811            proof_path.clone(),
23812            None,
23813            false,
23814        ).unwrap();
23815        let kimi = AgentId::new("kimi").unwrap();
23816        let context_only = NodeServer::context_pack_fixture_catalog(
23817            proof_path,
23818            Some(&kimi),
23819            false,
23820        )
23821        .unwrap();
23822
23823        let codex = AgentId::new("codex").unwrap();
23824        assert_eq!(
23825            standard.get(&codex).unwrap().launch.fixed_args,
23826            context_only.get(&codex).unwrap().launch.fixed_args,
23827        );
23828        let script = context_only
23829            .get(&kimi)
23830            .unwrap()
23831            .launch
23832            .fixed_args
23833            .iter()
23834            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23835            .expect("Kimi context-only validation script is missing");
23836        for required in [
23837            "$bundleArgs.Count -ne 0",
23838            "GATE4AGENT_CONTEXT_ROOT",
23839            "context-pack.json",
23840            "$entries.Count -ne 1",
23841            "g4a-context-pack-v1",
23842            "$document.PSObject.Properties['cwd']",
23843            "'claude', 'codex', 'grok', 'kimi'",
23844            "$hasUser",
23845            "$hasAssistant",
23846            "SHA256",
23847            "context-only",
23848        ] {
23849            assert!(script.contains(required), "missing validation: {required}");
23850        }
23851        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23852            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23853        }
23854        assert!(!standard
23855            .get(&kimi)
23856            .unwrap()
23857            .launch
23858            .fixed_args
23859            .iter()
23860            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23861
23862        std::fs::remove_dir_all(root).unwrap();
23863    }
23864
23865    #[cfg(all(feature = "fixture", windows))]
23866    #[tokio::test]
23867    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23868        let root = temporary_workspace_root("kimi-context-only-spawn");
23869        let workspace_root = root.join("workspace");
23870        std::fs::create_dir_all(&workspace_root).unwrap();
23871        let materialization_root = root.join("private-materializations");
23872        let proof_path = root.join("context.proof");
23873        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23874        let workspace_id = WorkspaceId::new("primary").unwrap();
23875        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23876        let endpoint = format!(
23877            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23878            std::process::id(),
23879            unix_time_ms(),
23880        );
23881        let spawn_profiles = SpawnProfileRegistry::new([
23882            crate::protocol::SpawnProfileDefaults {
23883                profile_id: profile_id.clone(),
23884                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23885                provider: agent("kimi"),
23886                mode: SessionMode::Pty,
23887                terminal_size: gate4agent_types::TerminalSize {
23888                    rows: 30,
23889                    columns: 120,
23890                },
23891                prompt: None,
23892                bundle_id: None,
23893                context_id: None,
23894                environment_profile_id: None,
23895            },
23896        ])
23897        .unwrap();
23898        let mut config = NodeServerConfig::new(
23899            &endpoint,
23900            "fixture-token",
23901            node_id.clone(),
23902            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23903        )
23904        .unwrap()
23905        .with_spawn_profiles(spawn_profiles)
23906        .with_session_environment_materialization(
23907            materialization_root,
23908            Arc::new(FixtureSecretResolver {
23909                deny: Arc::new(AtomicBool::new(false)),
23910            }),
23911        )
23912        .unwrap();
23913        config.fixture_raw_pty_runtime = true;
23914        let server = NodeServer::new_context_only_proof_fixture(
23915            config,
23916            agent("kimi"),
23917            proof_path.clone(),
23918        )
23919        .unwrap();
23920        let shared = Arc::clone(&server.shared);
23921        let shutdown = server.shutdown_handle();
23922
23923        let history = HistorySessionRecord {
23924            session_id: "context-only-source-history".to_owned(),
23925            title: None,
23926            cwd: None,
23927            model: None,
23928            message_count: 2,
23929            completed_turn_count: None,
23930            total_tokens: 0,
23931            messages: vec![
23932                gate4agent_types::HistoryMessageRecord {
23933                    role: gate4agent_types::HistoryMessageRole::User,
23934                    text: "continue the bounded parent run through native C2".to_owned(),
23935                },
23936                gate4agent_types::HistoryMessageRecord {
23937                    role: gate4agent_types::HistoryMessageRole::Assistant,
23938                    text: "the exact parent context is ready for the child run".to_owned(),
23939                },
23940            ],
23941        };
23942        let pack = NodeContextPack::export(
23943            ContextPackLineageReceipt {
23944                source_node_id: node_id.clone(),
23945                source_session: SessionAddress {
23946                    workspace_id: workspace_id.clone(),
23947                    session: SessionKey {
23948                        instance_id: AgentInstanceId(71),
23949                        generation: SessionGeneration(1),
23950                    },
23951                },
23952                source_provider: agent("codex"),
23953            },
23954            &history,
23955        )
23956        .unwrap();
23957        let expected_context_hash = digest(&SHA256, pack.bytes())
23958            .as_ref()
23959            .iter()
23960            .map(|byte| format!("{byte:02x}"))
23961            .collect::<String>();
23962        let context = shared
23963            .context_catalog
23964            .write()
23965            .unwrap_or_else(|poisoned| poisoned.into_inner())
23966            .insert(pack)
23967            .unwrap();
23968        let spec = SpawnSpec {
23969            target: crate::protocol::SpawnTarget {
23970                node_id,
23971                workspace_id: workspace_id.clone(),
23972                worktree_id: None,
23973            },
23974            profile_id,
23975            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23976                "target-kimi-r1",
23977            ).unwrap(),
23978            overrides: crate::protocol::SpawnOverrides {
23979                context_id: crate::protocol::SpawnOverride::Set {
23980                    value: context.id.clone(),
23981                },
23982                ..crate::protocol::SpawnOverrides::default()
23983            },
23984            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23985            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23986            required_capabilities: SpawnRequiredCapabilities::default(),
23987        };
23988        let resolved = match shared.resolve_spawn_spec(&spec) {
23989            Ok(resolved) => resolved,
23990            Err(error) => panic!(
23991                "Kimi context-only resolve rejected: exact_code={:?} message={}",
23992                error.code,
23993                error.message,
23994            ),
23995        };
23996        let resolved_context = match shared.resolve_context(&resolved) {
23997            Ok(Some(resolved_context)) => resolved_context,
23998            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23999            Err(error) => panic!(
24000                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24001                error.code,
24002                error.message,
24003            ),
24004        };
24005        assert_eq!(resolved_context, context);
24006        let runtime_policy = match shared
24007            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24008            .await
24009        {
24010            Ok(runtime_policy) => runtime_policy,
24011            Err(error) => panic!(
24012                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24013                error.code,
24014                error.message,
24015            ),
24016        };
24017        assert!(runtime_policy.raw_pty_lifecycle);
24018
24019        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24020        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24021            agent("kimi"),
24022            TransportKind::Pty,
24023            vec![EnvMutation {
24024                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24025                value: Some(OsString::from("must-remain-rejected")),
24026            }],
24027        )
24028        .unwrap();
24029        assert_eq!(
24030            launch_control
24031                .install_native_launch_environment_overlay(
24032                    AgentInstanceId(72),
24033                    generic_overlay,
24034                )
24035                .unwrap_err(),
24036            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24037        );
24038
24039        let diagnostic_address = SessionAddress {
24040            workspace_id: workspace_id.clone(),
24041            session: SessionKey {
24042                instance_id: AgentInstanceId(73),
24043                generation: SessionGeneration(1),
24044            },
24045        };
24046        let (diagnostic_overlay, diagnostic_materialization) = match shared
24047            .prepare_session_materialization(
24048                &diagnostic_address,
24049                &agent("kimi"),
24050                SessionMode::Pty,
24051                None,
24052                None,
24053                Some(&resolved_context),
24054                None,
24055            )
24056        {
24057            Ok(Some(prepared)) => prepared,
24058            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24059            Err(error) => panic!(
24060                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24061                error.code,
24062                error.message,
24063            ),
24064        };
24065        let diagnostic_overlay = diagnostic_overlay
24066            .expect("Kimi context-only materialization omitted its environment overlay");
24067        assert!(matches!(
24068            &diagnostic_overlay,
24069            PreparedNativeLaunchOverlay::Environment(_),
24070        ));
24071        match shared.install_prepared_launch_overlay(
24072            diagnostic_address.session.instance_id,
24073            diagnostic_overlay,
24074        ) {
24075            Ok(None) => {}
24076            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24077            Err(error) => panic!(
24078                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24079                error.code,
24080                error.message,
24081            ),
24082        }
24083        assert!(launch_control.clear_native_instance_launch_overlay(
24084            diagnostic_address.session.instance_id,
24085        ));
24086        drop(diagnostic_materialization);
24087        assert!(shared
24088            .materializations
24089            .lock()
24090            .unwrap_or_else(|poisoned| poisoned.into_inner())
24091            .is_empty());
24092
24093        let node_task = tokio::spawn(server.run());
24094        let ready_client = timeout(Duration::from_secs(5), async {
24095            loop {
24096                match gate4agent_node_wire::LocalNodeClient::connect(
24097                    &endpoint,
24098                    &shared.node_id,
24099                    ClientRole::Observer,
24100                    "fixture-token",
24101                )
24102                .await
24103                {
24104                    Ok(client) => break client,
24105                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24106                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24107                }
24108            }
24109        })
24110        .await
24111        .expect("Kimi context-only Node did not become ready");
24112        assert_eq!(
24113            ready_client
24114                .hello()
24115                .snapshot
24116                .provider_runtime_statuses
24117                .iter()
24118                .find(|status| status.provider().as_str() == "kimi")
24119                .expect("Kimi runtime status is missing")
24120                .mode(),
24121            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24122        );
24123        drop(ready_client);
24124
24125        let receipt = match shared.spawn_from_spec(spec).await {
24126            Ok(receipt) => receipt,
24127            Err(error) => panic!(
24128                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24129                error.code,
24130                error.message,
24131            ),
24132        };
24133        assert_eq!(receipt.provider, agent("kimi"));
24134        assert_eq!(receipt.context.as_ref(), Some(&context));
24135        assert!(receipt.context_binding_is_valid());
24136        timeout(Duration::from_secs(5), async {
24137            while !proof_path.is_file() {
24138                sleep(Duration::from_millis(10)).await;
24139            }
24140        })
24141        .await
24142        .expect("Kimi context-only fixture did not write its proof");
24143
24144        let proof = std::fs::read_to_string(&proof_path).unwrap();
24145        let lines = proof.lines().collect::<Vec<_>>();
24146        assert_eq!(lines.len(), 7);
24147        assert_eq!(lines[0], "context-only");
24148        assert!(Path::new(lines[1]).is_absolute());
24149        assert_ne!(
24150            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24151            std::fs::canonicalize(&workspace_root).unwrap(),
24152        );
24153        assert_eq!(
24154            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24155            std::fs::canonicalize(&workspace_root).unwrap(),
24156        );
24157        assert_eq!(lines[3], expected_context_hash);
24158        assert_eq!(lines[4], "g4a-context-pack-v1");
24159        assert_eq!(lines[5], "codex");
24160        assert_eq!(lines[6], "2");
24161        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24162            session.instance_id == receipt.session.session.instance_id
24163                && session.agent_id.as_str() == "kimi"
24164        }));
24165
24166        shutdown.request_shutdown().await.unwrap();
24167        timeout(Duration::from_secs(5), node_task)
24168            .await
24169            .unwrap()
24170            .unwrap()
24171            .unwrap();
24172        drop(shutdown);
24173        drop(shared);
24174        std::fs::remove_dir_all(root).unwrap();
24175    }
24176
24177    #[cfg(feature = "fixture")]
24178    #[test]
24179    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24180        let root = temporary_workspace_root("clean-exit-constructor");
24181        std::fs::create_dir_all(&root).unwrap();
24182        let config = construction_test_config("clean-exit-constructor")
24183            .with_state_path(root.join("node-state.json"))
24184            .unwrap();
24185        let server = NodeServer::new_clean_exit_fixture(
24186            config,
24187            root.clone(),
24188            root.join("started.marker"),
24189            root.join("release.signal"),
24190        )
24191        .unwrap();
24192        assert_eq!(
24193            server
24194                .shared
24195                .enabled_providers
24196                .iter()
24197                .map(AgentId::as_str)
24198                .collect::<Vec<_>>(),
24199            ["claude"],
24200        );
24201        drop(server);
24202        std::fs::remove_dir_all(root).unwrap();
24203    }
24204
24205    #[test]
24206    fn production_registry_provider_contract_manifest_is_exact() {
24207        let registry = active_registry().unwrap();
24208        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24209        assert_eq!(
24210            providers
24211                .iter()
24212                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24213                .collect::<Vec<_>>(),
24214            vec![
24215                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24216                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24217                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24218                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24219            ]
24220        );
24221        assert_eq!(
24222            adapters
24223                .iter()
24224                .map(|contract| (
24225                    contract.provider.as_str(),
24226                    contract.family,
24227                    contract.adapter_id.as_str(),
24228                    contract.revision.as_str(),
24229                ))
24230                .collect::<Vec<_>>(),
24231            vec![
24232                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24233                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24234                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24235                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24236                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24237                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24238                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24239                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24240                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24241                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24242                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24243                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24244                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24245                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24246                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24247                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24248                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24249                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24250                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24251                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24252                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24253                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24254                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24255            ]
24256        );
24257    }
24258
24259    fn construction_test_config(name: &str) -> NodeServerConfig {
24260        let workspace = WorkspaceConfig::new(
24261            WorkspaceId::new("primary").unwrap(),
24262            std::env::current_dir().unwrap(),
24263        )
24264        .unwrap();
24265        NodeServerConfig::new(
24266            format!(r"\\.\pipe\gate4agent-{name}"),
24267            "fixture-token",
24268            NodeId::new(name).unwrap(),
24269            [workspace],
24270        )
24271        .unwrap()
24272    }
24273
24274    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24275        let adapter = AdapterBinding::new(
24276            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24277            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24278            gate4agent_types::AdapterVerification::SyntheticFixture,
24279        )
24280        .unwrap();
24281        let agent_ids = ["claude", "codex", "kimi"]
24282            .into_iter()
24283            .map(|agent_id| AgentId::new(agent_id).unwrap())
24284            .collect::<Vec<_>>();
24285        let adapters = gate4agent_catalog::AdapterRegistry::new(
24286            [
24287                AdapterFamily::PtySemantic,
24288                AdapterFamily::Pipe,
24289                AdapterFamily::OneShot,
24290                AdapterFamily::Acp,
24291                AdapterFamily::Hook,
24292                AdapterFamily::ManagedHook,
24293                AdapterFamily::History,
24294                AdapterFamily::Resume,
24295                AdapterFamily::SessionOptions,
24296                AdapterFamily::CapabilityProbe,
24297            ]
24298            .into_iter()
24299            .map(|family| gate4agent_catalog::AdapterDescriptor {
24300                family,
24301                binding: adapter.clone(),
24302                agents: agent_ids.clone(),
24303            }),
24304        )
24305        .unwrap();
24306        let specs = agent_ids
24307            .iter()
24308            .map(|agent_id| {
24309                let agent_id = agent_id.as_str();
24310                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24311                spec.revision =
24312                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24313                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24314                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24315                    adapter: adapter.clone(),
24316                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24317                    launch_override: None,
24318                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24319                });
24320                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24321                    adapter: adapter.clone(),
24322                    launch_override: None,
24323                });
24324                spec.capabilities.adapters.hook = Some(adapter.clone());
24325                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24326                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24327                spec.capabilities.adapters.history = Some(adapter.clone());
24328                spec.capabilities.adapters.resume = Some(adapter.clone());
24329                spec.capabilities.adapters.session_options = Some(adapter.clone());
24330                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24331                spec
24332            })
24333            .collect::<Vec<_>>();
24334        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24335    }
24336
24337    /// The real fleet's manifest is accepted by a real server construction.
24338    ///
24339    /// The count is asserted against `active_registry` itself rather than a
24340    /// literal, so this stays a test that construction succeeds for whatever
24341    /// the fleet is -- which is the property worth guarding -- instead of a
24342    /// second place the fleet size is written down and has to be kept in
24343    /// step with the first.
24344    #[test]
24345    fn node_server_construction_accepts_the_production_manifest() {
24346        let registry = active_registry().unwrap();
24347        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24348        assert_eq!(providers.len(), registry.iter().count());
24349        assert!(!providers.is_empty());
24350        let server = NodeServer::new_with_registry(
24351            construction_test_config("manifest-production-bound"),
24352            registry,
24353        )
24354        .unwrap();
24355        drop(server);
24356    }
24357
24358    #[test]
24359    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24360        let registry = maximum_size_provider_manifest_registry();
24361        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24362        assert_eq!(providers.len(), 3);
24363        assert_eq!(adapters.len(), 30);
24364        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24365        let error = match NodeServer::new_with_registry(
24366            construction_test_config("manifest-handshake-overflow"),
24367            registry,
24368        ) {
24369            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24370            Err(error) => error,
24371        };
24372        assert!(matches!(
24373            error,
24374            NodeServerError::ProviderContractManifest(message)
24375                if message.contains("authentication binding length")
24376                    && message.contains("8192-byte limit")
24377        ));
24378    }
24379
24380    #[test]
24381    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24382        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24383        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24384        let registry = AgentRegistry::new([spec]).unwrap();
24385        assert!(matches!(
24386            provider_contract_manifest(&registry),
24387            Err(NodeServerError::ProviderContractManifest(message))
24388                if message.contains("exceeds the 128-byte limit")
24389        ));
24390    }
24391
24392    #[test]
24393    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24394        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24395        spec.revision = "invalid revision".to_owned();
24396        let registry = AgentRegistry::new([spec]).unwrap();
24397        assert!(matches!(
24398            provider_contract_manifest(&registry),
24399            Err(NodeServerError::ProviderContractManifest(message))
24400                if message.contains("bounded lowercase ASCII")
24401        ));
24402    }
24403
24404    #[test]
24405    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24406        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24407        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24408        let conflicting_binding = builtin_registry()
24409            .get_by_id("codex")
24410            .unwrap()
24411            .capabilities
24412            .adapters
24413            .one_shot
24414            .clone()
24415            .unwrap();
24416        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24417        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24418        pipe.adapter = transport_binding;
24419        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24420        let registry = AgentRegistry::new([spec]).unwrap();
24421        assert!(matches!(
24422            provider_contract_manifest(&registry),
24423            Err(NodeServerError::ProviderContractManifest(message))
24424                if message.contains("conflicting OneShot adapter bindings")
24425        ));
24426    }
24427
24428    #[test]
24429    fn public_node_failures_expose_only_fixed_categories() {
24430        let provider = failure(
24431            NodeFailureCode::BackendOperationFailed,
24432            r"provider bearer-secret failed at C:\private\session.jsonl",
24433        );
24434        assert_eq!(provider.message, "backend-operation-failed");
24435        assert!(!provider.message.contains("bearer-secret"));
24436        assert!(!provider.message.contains(r"C:\private"));
24437        let persistence = persistence_failure(io::Error::new(
24438            io::ErrorKind::Other,
24439            r"state write failed at C:\private\state-v1.json",
24440        ));
24441        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24442        assert!(!persistence.message.contains(r"C:\private"));
24443
24444        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24445            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24446            assert_eq!(
24447                generic.to_string(),
24448                "node durable state failed: durable-state-load-failed",
24449            );
24450        }
24451    }
24452
24453    #[test]
24454    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24455        let sequence = b"\x1b[1;5D".to_vec();
24456        assert_eq!(
24457            terminal_bytes_action(sequence.clone()).unwrap(),
24458            InputAction::TerminalBytes(sequence),
24459        );
24460        assert_eq!(
24461            terminal_bytes_action(Vec::new()).unwrap_err().code,
24462            NodeFailureCode::InvalidRequest,
24463        );
24464        assert_eq!(
24465            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24466                .unwrap_err()
24467                .code,
24468            NodeFailureCode::InvalidRequest,
24469        );
24470    }
24471
24472    #[test]
24473    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24474        let root = temporary_workspace_root("exclusive-state-owner");
24475        std::fs::create_dir_all(&root).unwrap();
24476        let workspace = WorkspaceConfig::new(
24477            WorkspaceId::new("primary").unwrap(),
24478            &root,
24479        )
24480        .unwrap();
24481        let state_path = root.join("state-v1.json");
24482        let endpoint = format!(
24483            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24484            std::process::id(),
24485            std::time::SystemTime::now()
24486                .duration_since(std::time::UNIX_EPOCH)
24487                .unwrap()
24488                .as_nanos(),
24489        );
24490        let config = NodeServerConfig::new(
24491            endpoint,
24492            "fixture-token",
24493            NodeId::new("node-1").unwrap(),
24494            [workspace],
24495        )
24496        .unwrap()
24497        .with_state_path(&state_path)
24498        .unwrap();
24499
24500        let first = NodeServer::new(config.clone()).unwrap();
24501        let error = match NodeServer::new(config.clone()) {
24502            Ok(_) => panic!("a second node server acquired the same durable state path"),
24503            Err(error) => error,
24504        };
24505        assert!(matches!(
24506            &error,
24507            NodeServerError::DurableState(error)
24508                if error.kind() == io::ErrorKind::WouldBlock
24509        ));
24510        assert_eq!(
24511            error.to_string(),
24512            "node durable state failed: durable-state-lock-failed",
24513        );
24514        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24515        drop(first);
24516        let second = NodeServer::new(config).unwrap();
24517        drop(second);
24518        std::fs::remove_dir_all(root).unwrap();
24519    }
24520
24521    #[test]
24522    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24523        let catalog = active_registry().unwrap();
24524        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24525        let workspace_id = WorkspaceId::new("primary").unwrap();
24526        let workspace = WorkspaceConfig::new(
24527            workspace_id.clone(),
24528            std::env::current_dir().unwrap(),
24529        )
24530        .unwrap();
24531        let shared = NodeShared::new(
24532            handle,
24533            "fixture-token".to_owned(),
24534            NodeId::new("node-1").unwrap(),
24535            vec![workspace.clone()],
24536            vec![agent("claude"), agent("codex"), agent("kimi")],
24537        );
24538        let instance_id = AgentInstanceId(17);
24539        let generation = SessionGeneration(3);
24540        assert!(shared.address_for(instance_id, generation).is_none());
24541        shared.bind_session(instance_id, workspace_id.clone(), generation);
24542        let address = SessionAddress {
24543            workspace_id,
24544            session: SessionKey { instance_id, generation },
24545        };
24546        assert_eq!(
24547            shared.workspace_root(&address.workspace_id).unwrap(),
24548            workspace.canonical_root,
24549        );
24550        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24551        shared.remove_binding(&address);
24552        assert!(shared.address_for(instance_id, generation).is_none());
24553    }
24554
24555    #[test]
24556    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24557        let address = SessionAddress {
24558            workspace_id: WorkspaceId::new("primary").unwrap(),
24559            session: SessionKey {
24560                instance_id: AgentInstanceId(7),
24561                generation: SessionGeneration(2),
24562            },
24563        };
24564        let record_id = SessionRecordId::new("record-a").unwrap();
24565        let size = gate4agent_types::TerminalSize {
24566            rows: 24,
24567            columns: 80,
24568        };
24569        let address_requests = [
24570            NodeRequest::Resume {
24571                session: address.clone(),
24572                terminal_size: size,
24573                initial_prompt: None,
24574            },
24575            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24576            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24577            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24578            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24579            NodeRequest::TerminalControl {
24580                session: address.clone(),
24581                control: TerminalControl::Enter,
24582            },
24583            NodeRequest::Resize { session: address.clone(), size },
24584            NodeRequest::Interrupt { session: address.clone() },
24585            NodeRequest::Stop { session: address.clone(), force: false },
24586            NodeRequest::Remove { session: address },
24587        ];
24588        for request in &address_requests {
24589            assert!(request_requires_open_provider_ids_with(
24590                request,
24591                |_| Some(agent("third-party-agent")),
24592                |_| Some(agent("claude")),
24593            ));
24594            assert!(!request_requires_open_provider_ids_with(
24595                request,
24596                |_| Some(agent("claude")),
24597                |_| Some(agent("claude")),
24598            ));
24599            assert!(request_requires_open_provider_ids_with(
24600                request,
24601                |_| None,
24602                |_| Some(agent("claude")),
24603            ));
24604        }
24605
24606        let record_requests = [
24607            NodeRequest::RenameSessionRecord {
24608                record_id: record_id.clone(),
24609                display_name: "renamed".to_owned(),
24610            },
24611            NodeRequest::ResumeSessionRecord {
24612                record_id: record_id.clone(),
24613                terminal_size: size,
24614                initial_prompt: None,
24615            },
24616            NodeRequest::ForgetSessionRecord { record_id },
24617        ];
24618        for request in &record_requests {
24619            assert!(request_requires_open_provider_ids_with(
24620                request,
24621                |_| Some(agent("claude")),
24622                |_| Some(agent("grok")),
24623            ));
24624            assert!(!request_requires_open_provider_ids_with(
24625                request,
24626                |_| Some(agent("claude")),
24627                |_| Some(agent("kimi")),
24628            ));
24629            assert!(request_requires_open_provider_ids_with(
24630                request,
24631                |_| Some(agent("claude")),
24632                |_| None,
24633            ));
24634        }
24635
24636        assert!(request_requires_open_provider_ids_with(
24637            &NodeRequest::Spawn {
24638                workspace_id: WorkspaceId::new("primary").unwrap(),
24639                provider: agent("third-party-agent"),
24640                mode: SessionMode::Pty,
24641                terminal_size: size,
24642                initial_prompt: None,
24643            },
24644            |_| None,
24645            |_| None,
24646        ));
24647        assert!(!request_requires_open_provider_ids_with(
24648            &NodeRequest::Snapshot,
24649            |_| None,
24650            |_| None,
24651        ));
24652        assert!(request_requires_open_provider_ids_with(
24653            &NodeRequest::ForgetContextPack {
24654                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24655            },
24656            |_| None,
24657            |_| None,
24658        ));
24659    }
24660
24661    #[test]
24662    fn legacy_projection_filters_open_record_replies_and_event_history() {
24663        let catalog = active_registry().unwrap();
24664        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24665        let workspace = WorkspaceConfig::new(
24666            WorkspaceId::new("primary").unwrap(),
24667            std::env::current_dir().unwrap(),
24668        )
24669        .unwrap();
24670        let shared = NodeShared::new(
24671            handle,
24672            "fixture-token".to_owned(),
24673            NodeId::new("node-1").unwrap(),
24674            vec![workspace],
24675            vec![agent("claude"), agent("grok")],
24676        );
24677        let legacy = record("claude", "legacy-record");
24678        let open = record("grok", "open-record");
24679        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24680            record: legacy.clone(),
24681        });
24682        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24683            record: open.clone(),
24684        });
24685        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24686            record_id: legacy.record_id.clone(),
24687        });
24688        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24689            record_id: open.record_id.clone(),
24690        });
24691        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24692        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24693        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24694        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24695
24696        let mut record_reply = ResponseEnvelope {
24697            request_id: 1,
24698            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24699        };
24700        project_response_legacy_provider_ids(&shared, &mut record_reply);
24701        assert!(matches!(
24702            record_reply.result,
24703            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24704        ));
24705
24706        let mut resync = ResponseEnvelope {
24707            request_id: 2,
24708            result: Ok(NodeResponse::Resync {
24709                event_sequence: open_removed.sequence,
24710                oldest_available_sequence: 1,
24711                snapshot: shared.snapshot(),
24712                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24713            }),
24714        };
24715        project_response_legacy_provider_ids(&shared, &mut resync);
24716        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24717            panic!("legacy projection changed the resync response kind");
24718        };
24719        assert!(snapshot.session_records.iter().all(|item| {
24720            provider_id_is_legacy(&item.provider)
24721        }));
24722        assert_eq!(events.len(), 2);
24723        assert!(events.iter().all(|event| matches!(
24724            &event.event,
24725            NodeEvent::SessionRecordUpserted { record }
24726                if provider_id_is_legacy(&record.provider)
24727        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24728    }
24729
24730    #[test]
24731    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24732        let catalog = active_registry().unwrap();
24733        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24734        let workspace_id = WorkspaceId::new("primary").unwrap();
24735        let workspace = WorkspaceConfig::new(
24736            workspace_id.clone(),
24737            std::env::current_dir().unwrap(),
24738        )
24739        .unwrap();
24740        let shared = NodeShared::new(
24741            handle,
24742            "fixture-token".to_owned(),
24743            NodeId::new("node-1").unwrap(),
24744            vec![workspace],
24745            vec![agent("claude")],
24746        );
24747        for index in 0..CONTROL_SESSIONS_MAX {
24748            shared.bind_session(
24749                AgentInstanceId((index + 1) as u64),
24750                workspace_id.clone(),
24751                SessionGeneration(1),
24752            );
24753        }
24754        let error = shared.ensure_binding_capacity().unwrap_err();
24755        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24756    }
24757
24758    #[tokio::test]
24759    async fn provider_spawn_admission_rejects_before_session_mutation() {
24760        let catalog = active_registry().unwrap();
24761        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24762        let workspace = WorkspaceConfig::new(
24763            WorkspaceId::new("primary").unwrap(),
24764            std::env::current_dir().unwrap(),
24765        )
24766        .unwrap();
24767        let mut shared = NodeShared::new(
24768            handle,
24769            "fixture-token".to_owned(),
24770            NodeId::new("node-provider-admission").unwrap(),
24771            vec![workspace],
24772            vec![agent("claude")],
24773        );
24774        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24775            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24776        ])
24777        .unwrap();
24778        let terminal_size = gate4agent_types::TerminalSize {
24779            rows: 24,
24780            columns: 80,
24781        };
24782
24783        let unavailable = shared
24784            .spawn_session(
24785                WorkspaceId::new("primary").unwrap(),
24786                agent("claude"),
24787                SessionMode::Pty,
24788                terminal_size,
24789                None,
24790            )
24791            .await
24792            .unwrap_err();
24793        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24794        assert_eq!(unavailable.message, "backend-operation-failed");
24795
24796        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24797            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24798                agent("claude"),
24799                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24800            ),
24801        ])
24802        .unwrap();
24803        let semantic = shared
24804            .spawn_session(
24805                WorkspaceId::new("primary").unwrap(),
24806                agent("claude"),
24807                SessionMode::Inline,
24808                terminal_size,
24809                None,
24810            )
24811            .await
24812            .unwrap_err();
24813        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24814        assert_eq!(semantic.message, "unsupported-capability");
24815
24816        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24817        assert!(shared
24818            .session_bindings
24819            .lock()
24820            .unwrap_or_else(|poisoned| poisoned.into_inner())
24821            .is_empty());
24822        assert!(shared
24823            .session_records
24824            .lock()
24825            .unwrap_or_else(|poisoned| poisoned.into_inner())
24826            .records
24827            .is_empty());
24828        assert!(shared.handle.snapshot().sessions.is_empty());
24829    }
24830
24831    #[tokio::test]
24832    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24833        let catalog = active_registry().unwrap();
24834        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24835        let workspace_id = WorkspaceId::new("primary").unwrap();
24836        let workspace = WorkspaceConfig::new(
24837            workspace_id.clone(),
24838            std::env::current_dir().unwrap(),
24839        )
24840        .unwrap();
24841        let shared = NodeShared::new(
24842            handle,
24843            "fixture-token".to_owned(),
24844            NodeId::new("node-raw-spawn-record").unwrap(),
24845            vec![workspace],
24846            vec![agent("claude")],
24847        );
24848        let address = SessionAddress {
24849            workspace_id,
24850            session: SessionKey {
24851                instance_id: AgentInstanceId(71),
24852                generation: SessionGeneration::default(),
24853            },
24854        };
24855
24856        let record_id = shared
24857            .bind_spawn_session(
24858                &address,
24859                agent("claude"),
24860                SessionMode::Pty,
24861                ProviderRuntimePolicy::raw_pty(),
24862                SpawnRecordPolicy::Always,
24863                None,
24864            )
24865            .unwrap()
24866            .unwrap();
24867        shared
24868            .handle
24869            .dispatch(shared.prepare_command(ControlCommand::Register {
24870                instance_id: address.session.instance_id,
24871                agent_id: agent("claude"),
24872                transport: TransportKind::Pty,
24873            }))
24874            .unwrap();
24875        runtime.tick().await;
24876
24877        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24878            session.instance_id == address.session.instance_id
24879                && session.generation == address.session.generation
24880        }));
24881        let record = shared.record(&record_id).unwrap();
24882        assert_eq!(record.display_name, "claude #1");
24883        assert_eq!(record.state, ManagedSessionState::Live);
24884        assert_eq!(record.active_session.as_ref(), Some(&address));
24885        assert!(record.provider_session.is_none());
24886        let binding_record_id = shared
24887            .session_bindings
24888            .lock()
24889            .unwrap_or_else(|poisoned| poisoned.into_inner())
24890            .get(&address.session.instance_id)
24891            .and_then(|binding| binding.record_id.clone());
24892        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24893
24894        let renamed = shared
24895            .rename_session_record(&record_id, "raw review".to_owned())
24896            .unwrap();
24897        assert_eq!(renamed.record_id, record_id);
24898        assert_eq!(renamed.display_name, "raw review");
24899        shared.reconcile_managed_record(
24900            &record_id,
24901            &address,
24902            &ControlEvent {
24903                sequence: 1,
24904                command_id: None,
24905                instance_id: address.session.instance_id,
24906                generation: address.session.generation,
24907                event: ControlEventKind::Running { process_id: None },
24908            },
24909            false,
24910            false,
24911        );
24912        let running = shared.record(&record_id).unwrap();
24913        assert_eq!(running.state, ManagedSessionState::Live);
24914        assert_eq!(running.active_session.as_ref(), Some(&address));
24915        assert_eq!(running.display_name, "raw review");
24916
24917        shared.reconcile_managed_record(
24918            &record_id,
24919            &address,
24920            &ControlEvent {
24921                sequence: 2,
24922                command_id: None,
24923                instance_id: address.session.instance_id,
24924                generation: address.session.generation,
24925                event: ControlEventKind::Exited {
24926                    exit_code: Some(0),
24927                    forced: false,
24928                },
24929            },
24930            false,
24931            false,
24932        );
24933        let detached = shared.record(&record_id).unwrap();
24934        assert_eq!(detached.state, ManagedSessionState::Unavailable);
24935        assert!(detached.active_session.is_none());
24936        assert_eq!(detached.display_name, "raw review");
24937        let renamed_detached = shared
24938            .rename_session_record(&record_id, "raw review detached".to_owned())
24939            .unwrap();
24940        assert_eq!(renamed_detached.record_id, record_id);
24941        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24942        assert!(renamed_detached.active_session.is_none());
24943        assert_eq!(renamed_detached.display_name, "raw review detached");
24944        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24945    }
24946
24947    #[test]
24948    fn legacy_raw_spawn_binding_remains_untracked() {
24949        let catalog = active_registry().unwrap();
24950        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24951        let workspace_id = WorkspaceId::new("primary").unwrap();
24952        let workspace = WorkspaceConfig::new(
24953            workspace_id.clone(),
24954            std::env::current_dir().unwrap(),
24955        )
24956        .unwrap();
24957        let shared = NodeShared::new(
24958            handle,
24959            "fixture-token".to_owned(),
24960            NodeId::new("node-legacy-raw-spawn").unwrap(),
24961            vec![workspace],
24962            vec![agent("claude")],
24963        );
24964        let address = SessionAddress {
24965            workspace_id,
24966            session: SessionKey {
24967                instance_id: AgentInstanceId(72),
24968                generation: SessionGeneration::default(),
24969            },
24970        };
24971
24972        let record_id = shared
24973            .bind_spawn_session(
24974                &address,
24975                agent("claude"),
24976                SessionMode::Pty,
24977                ProviderRuntimePolicy::raw_pty(),
24978                SpawnRecordPolicy::ProviderIdentityOnly,
24979                None,
24980            )
24981            .unwrap();
24982
24983        assert!(record_id.is_none());
24984        let binding_record_id = shared
24985            .session_bindings
24986            .lock()
24987            .unwrap_or_else(|poisoned| poisoned.into_inner())
24988            .get(&address.session.instance_id)
24989            .and_then(|binding| binding.record_id.clone());
24990        assert!(binding_record_id.is_none());
24991        assert!(shared.snapshot().session_records.is_empty());
24992    }
24993
24994    #[tokio::test]
24995    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24996        let catalog = active_registry().unwrap();
24997        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24998        let workspace_id = WorkspaceId::new("primary").unwrap();
24999        let workspace = WorkspaceConfig::new(
25000            workspace_id.clone(),
25001            std::env::current_dir().unwrap(),
25002        )
25003        .unwrap();
25004        let mut shared = NodeShared::new(
25005            handle,
25006            "fixture-token".to_owned(),
25007            NodeId::new("node-raw-semantic-gate").unwrap(),
25008            vec![workspace],
25009            vec![agent("claude")],
25010        );
25011        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25012            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25013        ])
25014        .unwrap();
25015        shared
25016            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25017            .unwrap();
25018        let address = SessionAddress {
25019            workspace_id,
25020            session: SessionKey {
25021                instance_id: AgentInstanceId(72),
25022                generation: SessionGeneration::default(),
25023            },
25024        };
25025        shared.bind_session_with_policy(
25026            &address,
25027            ProviderRuntimePolicy::raw_pty(),
25028            None,
25029        );
25030        shared
25031            .handle
25032            .dispatch(shared.prepare_command(ControlCommand::Register {
25033                instance_id: address.session.instance_id,
25034                agent_id: agent("claude"),
25035                transport: TransportKind::Pty,
25036            }))
25037            .unwrap();
25038        runtime.tick().await;
25039        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25040        let revision_before = shared.handle.snapshot().revision;
25041        let history_sequence_before = shared.current_sequence();
25042        let terminal_size = gate4agent_types::TerminalSize {
25043            rows: 24,
25044            columns: 80,
25045        };
25046
25047        for request in [
25048            NodeRequest::Prompt {
25049                session: address.clone(),
25050                text: "prompt".to_owned(),
25051            },
25052            NodeRequest::Paste {
25053                session: address.clone(),
25054                text: "paste".to_owned(),
25055            },
25056            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25057            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25058            // (promptless) resume is a raw PTY relaunch and IS admitted under
25059            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25060            // in `provider_runtime.rs`; it is deliberately not exercised here.
25061            NodeRequest::Resume {
25062                session: address.clone(),
25063                terminal_size,
25064                initial_prompt: Some("resume".to_owned()),
25065            },
25066        ] {
25067            let error = process_request_inner(
25068                &shared,
25069                77,
25070                ClientRole::Operator,
25071                request,
25072            )
25073            .await
25074            .unwrap_err();
25075            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25076        }
25077
25078        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25079        assert_eq!(shared.handle.snapshot().revision, revision_before);
25080        assert_eq!(shared.current_sequence(), history_sequence_before);
25081        let bindings = shared
25082            .session_bindings
25083            .lock()
25084            .unwrap_or_else(|poisoned| poisoned.into_inner());
25085        let binding = bindings.get(&address.session.instance_id).unwrap();
25086        assert_eq!(binding.generation, address.session.generation);
25087        assert!(binding.pending_resume.is_none());
25088        assert!(binding.record_id.is_none());
25089    }
25090
25091    #[tokio::test]
25092    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25093        let catalog = active_registry().unwrap();
25094        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25095        let primary = WorkspaceConfig::new(
25096            WorkspaceId::new("primary").unwrap(),
25097            std::env::current_dir().unwrap(),
25098        )
25099        .unwrap();
25100        let shared = NodeShared::new(
25101            handle,
25102            "fixture-token".to_owned(),
25103            NodeId::new("node-1").unwrap(),
25104            vec![primary],
25105            vec![agent("claude")],
25106        );
25107        shared
25108            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25109            .unwrap();
25110        let before = shared.snapshot();
25111        let error = process_request_inner(
25112            &shared,
25113            77,
25114            ClientRole::Operator,
25115            NodeRequest::RegisterWorkspace {
25116                workspace_id: WorkspaceId::new("foreign").unwrap(),
25117                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25118            },
25119        )
25120        .await
25121        .unwrap_err();
25122        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25123        assert_eq!(error.message, "invalid-request");
25124        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25125    }
25126
25127    #[tokio::test]
25128    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25129        let catalog = active_registry().unwrap();
25130        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25131        let primary_id = WorkspaceId::new("primary").unwrap();
25132        let primary = WorkspaceConfig::new(
25133            primary_id.clone(),
25134            std::env::current_dir().unwrap(),
25135        )
25136        .unwrap();
25137        let shared = NodeShared::new(
25138            handle,
25139            "fixture-token".to_owned(),
25140            NodeId::new("node-1").unwrap(),
25141            vec![primary.clone()],
25142            vec![agent("claude")],
25143        );
25144        let secondary_id = WorkspaceId::new("secondary").unwrap();
25145        let secondary = shared
25146            .register_workspace(
25147                secondary_id.clone(),
25148                std::env::temp_dir().to_string_lossy().into_owned(),
25149            )
25150            .await
25151            .unwrap();
25152        assert_eq!(secondary.workspace_id, secondary_id);
25153        assert!(secondary.sessions.is_empty());
25154        assert_eq!(shared.snapshot().workspaces.len(), 2);
25155
25156        let duplicate_id = shared
25157            .register_workspace(
25158                secondary_id.clone(),
25159                primary.canonical_root().to_owned(),
25160            )
25161            .await
25162            .unwrap_err();
25163        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25164        let duplicate_root = shared
25165            .register_workspace(
25166                WorkspaceId::new("duplicate-root").unwrap(),
25167                windows_path_text(&secondary.canonical_root).to_owned(),
25168            )
25169            .await
25170            .unwrap_err();
25171        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25172
25173        let address = SessionAddress {
25174            workspace_id: secondary_id.clone(),
25175            session: SessionKey {
25176                instance_id: AgentInstanceId(41),
25177                generation: SessionGeneration(1),
25178            },
25179        };
25180        shared.bind_session(
25181            address.session.instance_id,
25182            secondary_id.clone(),
25183            address.session.generation,
25184        );
25185        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25186        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25187        shared.remove_binding(&address);
25188        shared.unregister_workspace(&secondary_id).unwrap();
25189        assert_eq!(shared.snapshot().workspaces.len(), 1);
25190
25191        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25192        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25193        let history = shared
25194            .history
25195            .lock()
25196            .unwrap_or_else(|poisoned| poisoned.into_inner());
25197        assert!(matches!(
25198            &history.events[0],
25199            NodeEventEnvelope {
25200                sequence: 1,
25201                event: NodeEvent::WorkspaceAdded { workspace },
25202            } if workspace.workspace_id == secondary_id
25203        ));
25204        assert!(matches!(
25205            &history.events[1],
25206            NodeEventEnvelope {
25207                sequence: 2,
25208                event: NodeEvent::WorkspaceRemoved { workspace_id },
25209            } if workspace_id == &secondary_id
25210        ));
25211    }
25212
25213    #[tokio::test]
25214    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25215        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25216        std::fs::create_dir_all(&secondary_root).unwrap();
25217        let catalog = active_registry().unwrap();
25218        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25219        let primary_id = WorkspaceId::new("primary").unwrap();
25220        let secondary_id = WorkspaceId::new("secondary").unwrap();
25221        let shared = NodeShared::new(
25222            handle,
25223            "fixture-token".to_owned(),
25224            NodeId::new("node-session-reference-index").unwrap(),
25225            vec![
25226                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25227                    .unwrap(),
25228                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25229            ],
25230            vec![agent("claude"), agent("codex")],
25231        );
25232        let identity = ProviderSessionIdentity {
25233            key: gate4agent_types::ProviderSessionKey::SessionId,
25234            id: "provider-session-42".to_owned(),
25235            transcript_path: None,
25236        };
25237        let index_request = |workspace_id: WorkspaceId,
25238                             provider: AgentId,
25239                             identity: ProviderSessionIdentity,
25240                             display_name: &str| {
25241            NodeRequest::IndexProviderSession {
25242                workspace_id,
25243                provider,
25244                identity,
25245                display_name: display_name.to_owned(),
25246            }
25247        };
25248        let request = index_request(
25249            primary_id.clone(),
25250            agent("claude"),
25251            identity.clone(),
25252            "release shepherd",
25253        );
25254        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25255            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25256        }));
25257        let observer = process_request_inner(
25258            &shared,
25259            77,
25260            ClientRole::Observer,
25261            request.clone(),
25262        )
25263        .await
25264        .unwrap_err();
25265        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25266        let uncontrolled = process_request_inner(
25267            &shared,
25268            77,
25269            ClientRole::Operator,
25270            request.clone(),
25271        )
25272        .await
25273        .unwrap_err();
25274        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25275        shared
25276            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25277            .unwrap();
25278
25279        let indexed = process_request_inner(
25280            &shared,
25281            77,
25282            ClientRole::Operator,
25283            request.clone(),
25284        )
25285        .await
25286        .unwrap();
25287        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25288            panic!("provider session reference index returned another response");
25289        };
25290        assert_eq!(record.state, ManagedSessionState::Dormant);
25291        assert_eq!(record.mode, SessionMode::Pty);
25292        assert_eq!(record.workspace_id, primary_id);
25293        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25294        assert!(record.active_session.is_none());
25295        assert!(record.environment_profile.is_none());
25296        assert!(record.bundle.is_none());
25297        assert!(record.context.is_none());
25298
25299        let repeated = process_request_inner(
25300            &shared,
25301            77,
25302            ClientRole::Operator,
25303            index_request(
25304                primary_id.clone(),
25305                agent("claude"),
25306                identity.clone(),
25307                "ignored replacement",
25308            ),
25309        )
25310        .await
25311        .unwrap();
25312        assert!(matches!(
25313            repeated,
25314            NodeResponse::ProviderSessionIndexed { record: ref existing }
25315                if existing.record_id == record.record_id
25316                    && existing.display_name == "release shepherd"
25317        ));
25318        let conflicting_workspace = process_request_inner(
25319            &shared,
25320            77,
25321            ClientRole::Operator,
25322            index_request(
25323                secondary_id,
25324                agent("claude"),
25325                identity.clone(),
25326                "release shepherd",
25327            ),
25328        )
25329        .await
25330        .unwrap_err();
25331        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25332        let conflicting_provider = process_request_inner(
25333            &shared,
25334            77,
25335            ClientRole::Operator,
25336            index_request(
25337                primary_id.clone(),
25338                agent("codex"),
25339                identity.clone(),
25340                "release shepherd",
25341            ),
25342        )
25343        .await
25344        .unwrap_err();
25345        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25346        let transcript_rejected = process_request_inner(
25347            &shared,
25348            77,
25349            ClientRole::Operator,
25350            index_request(
25351                primary_id,
25352                agent("claude"),
25353                ProviderSessionIdentity {
25354                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25355                    ..identity
25356                },
25357                "release shepherd",
25358            ),
25359        )
25360        .await
25361        .unwrap_err();
25362        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25363        let upserts = shared
25364            .history
25365            .lock()
25366            .unwrap_or_else(|poisoned| poisoned.into_inner())
25367            .events
25368            .iter()
25369            .filter(|event| matches!(
25370                &event.event,
25371                NodeEvent::SessionRecordUpserted { record: published }
25372                    if published.record_id == record.record_id
25373            ))
25374            .count();
25375        assert_eq!(upserts, 1);
25376        std::fs::remove_dir_all(secondary_root).unwrap();
25377    }
25378
25379    #[tokio::test]
25380    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25381        let fixture_root = temporary_workspace_root("standalone-independent");
25382        let target = fixture_root.join("independent");
25383        std::fs::create_dir_all(&fixture_root).unwrap();
25384        let catalog = active_registry().unwrap();
25385        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25386        let selected = WorkspaceConfig::new(
25387            WorkspaceId::new("primary").unwrap(),
25388            std::env::current_dir().unwrap(),
25389        )
25390        .unwrap();
25391        let selected_root = selected.canonical_root().to_owned();
25392        let shared = NodeShared::new(
25393            handle,
25394            "fixture-token".to_owned(),
25395            NodeId::new("node-standalone").unwrap(),
25396            vec![selected],
25397            vec![agent("claude")],
25398        );
25399        let workspace_id = WorkspaceId::new("independent").unwrap();
25400        let request = NodeRequest::CreateStandaloneWorkspace {
25401            workspace_id: workspace_id.clone(),
25402            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25403            initial_branch: Some("main".to_owned()),
25404        };
25405        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25406            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25407        }));
25408        assert!(!request_uses_unnegotiated_capability(
25409            &request,
25410            &baseline_capabilities().unwrap(),
25411        ));
25412        assert!(request_uses_unnegotiated_capability(&request, &[]));
25413
25414        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25415            .await
25416            .unwrap_err();
25417        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25418        assert!(!target.exists());
25419        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25420            .await
25421            .unwrap_err();
25422        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25423        assert!(!target.exists());
25424        shared
25425            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25426            .unwrap();
25427
25428        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25429            .await
25430            .unwrap();
25431        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25432            panic!("standalone workspace creation returned another response");
25433        };
25434        assert_eq!(workspace.workspace_id, workspace_id);
25435        assert!(target.join(".git").is_dir());
25436        let branch = run_git_bounded(
25437            target.to_str().unwrap(),
25438            &["branch", "--show-current"],
25439            GIT_OUTPUT_MAX_BYTES,
25440        )
25441        .await
25442        .unwrap();
25443        assert!(branch.success);
25444        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25445        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25446        assert_eq!(worktrees.len(), 1);
25447        let listed_root = WorkspaceConfig::new(
25448            WorkspaceId::new("listed-independent").unwrap(),
25449            &worktrees[0].path,
25450        )
25451        .unwrap();
25452        assert!(platform::roots_equal(
25453            listed_root.canonical_root(),
25454            windows_path_text(&workspace.canonical_root),
25455        ));
25456        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25457        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25458            snapshot.workspace_id == workspace_id
25459                && snapshot.canonical_root == workspace.canonical_root
25460        }));
25461        assert!(shared
25462            .history
25463            .lock()
25464            .unwrap_or_else(|poisoned| poisoned.into_inner())
25465            .events
25466            .iter()
25467            .any(|event| matches!(
25468                &event.event,
25469                NodeEvent::WorkspaceAdded { workspace }
25470                    if workspace.workspace_id == workspace_id
25471            )));
25472        std::fs::remove_dir_all(fixture_root).unwrap();
25473    }
25474
25475    #[tokio::test]
25476    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25477        let fixture_root = temporary_workspace_root("standalone-compensation");
25478        let created_target = fixture_root.join("created-target");
25479        let existing_target = fixture_root.join("existing-target");
25480        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25481        let invalid_state_path = invalid_state_parent.join("state.json");
25482        std::fs::create_dir_all(&existing_target).unwrap();
25483        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25484        let catalog = active_registry().unwrap();
25485        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25486        let primary = WorkspaceConfig::new(
25487            WorkspaceId::new("primary").unwrap(),
25488            std::env::current_dir().unwrap(),
25489        )
25490        .unwrap();
25491        let mut shared = NodeShared::new(
25492            handle,
25493            "fixture-token".to_owned(),
25494            NodeId::new("node-standalone-compensation").unwrap(),
25495            vec![primary],
25496            vec![agent("claude")],
25497        );
25498        shared.state_path = Some(invalid_state_path);
25499
25500        let created_recovery = shared
25501            .create_standalone_workspace(
25502                WorkspaceId::new("compensated").unwrap(),
25503                created_target.to_string_lossy().into_owned(),
25504                Some("main".to_owned()),
25505            )
25506            .await
25507            .unwrap_err();
25508        assert_eq!(
25509            created_recovery.code,
25510            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25511        );
25512        assert!(created_target.join(".git").is_dir());
25513        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25514            workspace.workspace_id.as_str() != "compensated"
25515        }));
25516
25517        let existing_recovery = shared
25518            .create_standalone_workspace(
25519                WorkspaceId::new("restored").unwrap(),
25520                existing_target.to_string_lossy().into_owned(),
25521                None,
25522            )
25523            .await
25524            .unwrap_err();
25525        assert_eq!(
25526            existing_recovery.code,
25527            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25528        );
25529        assert!(existing_target.is_dir());
25530        assert!(existing_target.join(".git").is_dir());
25531        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25532            workspace.workspace_id.as_str() != "restored"
25533        }));
25534
25535        let registered_empty = fixture_root.join("registered-empty");
25536        std::fs::create_dir(&registered_empty).unwrap();
25537        let catalog = active_registry().unwrap();
25538        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25539        let registered = WorkspaceConfig::new(
25540            WorkspaceId::new("registered-empty").unwrap(),
25541            &registered_empty,
25542        )
25543        .unwrap();
25544        let duplicate_shared = NodeShared::new(
25545            handle,
25546            "fixture-token".to_owned(),
25547            NodeId::new("node-standalone-duplicate-root").unwrap(),
25548            vec![registered],
25549            vec![agent("claude")],
25550        );
25551        let duplicate = duplicate_shared
25552            .create_standalone_workspace(
25553                WorkspaceId::new("duplicate-root").unwrap(),
25554                registered_empty.to_string_lossy().into_owned(),
25555                None,
25556            )
25557            .await
25558            .unwrap_err();
25559        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25560        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25561        std::fs::remove_dir_all(fixture_root).unwrap();
25562    }
25563
25564    #[test]
25565    fn managed_record_id_collision_preserves_the_existing_record() {
25566        let catalog = active_registry().unwrap();
25567        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25568        let workspace = WorkspaceConfig::new(
25569            WorkspaceId::new("primary").unwrap(),
25570            std::env::current_dir().unwrap(),
25571        )
25572        .unwrap();
25573        let shared = NodeShared::new(
25574            handle,
25575            "fixture-token".to_owned(),
25576            NodeId::new("node-1").unwrap(),
25577            vec![workspace.clone()],
25578            vec![agent("claude")],
25579        );
25580        let record_id = SessionRecordId::new("sr-collision").unwrap();
25581        let original = ManagedSessionRecord {
25582            record_id: record_id.clone(),
25583            display_name: "original".to_owned(),
25584            provider: agent("claude"),
25585            mode: SessionMode::Pty,
25586            state: ManagedSessionState::Dormant,
25587            workspace_id: workspace.workspace_id,
25588            canonical_root: opaque_windows_path(workspace.canonical_root),
25589            provider_session: None,
25590            active_session: None,
25591            environment_profile: None,
25592            bundle: None,
25593            context_id: None,
25594            context: None,
25595            exported_context: None,
25596            task_binding: None,
25597            created_at_unix_ms: 1,
25598            updated_at_unix_ms: 1,
25599            last_error: None,
25600        };
25601        shared.insert_record(original.clone()).unwrap();
25602        let mut collision = original.clone();
25603        collision.display_name = "replacement".to_owned();
25604        let error = shared.insert_record(collision).unwrap_err();
25605        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25606        assert_eq!(shared.record(&record_id).unwrap(), original);
25607    }
25608
25609    #[test]
25610    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25611        let catalog = active_registry().unwrap();
25612        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25613        let workspace = WorkspaceConfig::new(
25614            WorkspaceId::new("primary").unwrap(),
25615            std::env::current_dir().unwrap(),
25616        )
25617        .unwrap();
25618        let shared = NodeShared::new(
25619            handle,
25620            "fixture-token".to_owned(),
25621            NodeId::new("node-1").unwrap(),
25622            vec![workspace.clone()],
25623            vec![agent("claude")],
25624        );
25625        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25626        shared
25627            .insert_record(ManagedSessionRecord {
25628                record_id: record_id.clone(),
25629                display_name: "runtime error".to_owned(),
25630                provider: agent("claude"),
25631                mode: SessionMode::Pty,
25632                state: ManagedSessionState::IdentityPending,
25633                workspace_id: workspace.workspace_id,
25634                canonical_root: opaque_windows_path(workspace.canonical_root),
25635                provider_session: None,
25636                active_session: None,
25637                environment_profile: None,
25638                bundle: None,
25639                context_id: None,
25640                context: None,
25641                exported_context: None,
25642                task_binding: None,
25643                created_at_unix_ms: 1,
25644                updated_at_unix_ms: 1,
25645                last_error: None,
25646            })
25647            .unwrap();
25648
25649        shared
25650            .mark_record_error(
25651                &record_id,
25652                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25653            )
25654            .unwrap();
25655        let record = shared.record(&record_id).unwrap();
25656        assert_eq!(record.state, ManagedSessionState::Unavailable);
25657        assert_eq!(
25658            record.last_error.as_deref(),
25659            Some("provider-runtime-failed"),
25660        );
25661        let snapshot = shared.snapshot();
25662        assert_eq!(snapshot.session_records, vec![record]);
25663    }
25664
25665    /// Regression coverage for the "operator sees `deadline` instead of the
25666    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25667    /// able to learn a command's asynchronous kernel rejection (here,
25668    /// specifically classified as `UnsupportedTransport`) exactly once, and
25669    /// must not still find it after that -- proving the fast path this
25670    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25671    /// loop) drains the reason instead of leaving it to be rediscovered (or
25672    /// missed) on a later poll.
25673    #[test]
25674    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25675        let catalog = active_registry().unwrap();
25676        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25677        let workspace = WorkspaceConfig::new(
25678            WorkspaceId::new("primary").unwrap(),
25679            std::env::current_dir().unwrap(),
25680        )
25681        .unwrap();
25682        let shared = NodeShared::new(
25683            handle,
25684            "fixture-token".to_owned(),
25685            NodeId::new("node-1").unwrap(),
25686            vec![workspace],
25687            vec![agent("claude")],
25688        );
25689
25690        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25691
25692        shared.record_rejected_command(
25693            CommandId(1),
25694            "agent 'claude' does not support transport Acp".to_owned(),
25695            true,
25696        );
25697        // An unrelated command_id must not see another command's reason.
25698        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25699
25700        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25701        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25702        assert!(reason.unsupported_transport);
25703
25704        // Consumed: a second read for the same command_id finds nothing.
25705        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25706
25707        // A rejection unrelated to transport support keeps its own
25708        // classification distinct, so the reader can choose the generic
25709        // `BackendOperationFailed` code instead of misreporting
25710        // `UnsupportedTransport`.
25711        shared.record_rejected_command(
25712            CommandId(3),
25713            "backend busy".to_owned(),
25714            false,
25715        );
25716        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25717        assert!(!unclassified.unsupported_transport);
25718    }
25719
25720    #[test]
25721    fn provider_identity_update_is_ignored_without_exact_session_policy() {
25722        let catalog = active_registry().unwrap();
25723        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25724        let workspace = WorkspaceConfig::new(
25725            WorkspaceId::new("primary").unwrap(),
25726            std::env::current_dir().unwrap(),
25727        )
25728        .unwrap();
25729        let shared = NodeShared::new(
25730            handle,
25731            "fixture-token".to_owned(),
25732            NodeId::new("node-identity-policy").unwrap(),
25733            vec![workspace.clone()],
25734            vec![agent("claude")],
25735        );
25736        let address = SessionAddress {
25737            workspace_id: workspace.workspace_id.clone(),
25738            session: SessionKey {
25739                instance_id: AgentInstanceId(90),
25740                generation: SessionGeneration(1),
25741            },
25742        };
25743        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25744        shared
25745            .insert_record(ManagedSessionRecord {
25746                record_id: record_id.clone(),
25747                display_name: "policy denied".to_owned(),
25748                provider: agent("claude"),
25749                mode: SessionMode::Pty,
25750                state: ManagedSessionState::IdentityPending,
25751                workspace_id: workspace.workspace_id,
25752                canonical_root: opaque_windows_path(workspace.canonical_root),
25753                provider_session: None,
25754                active_session: Some(address.clone()),
25755                environment_profile: None,
25756                bundle: None,
25757                context_id: None,
25758                context: None,
25759                exported_context: None,
25760                task_binding: None,
25761                created_at_unix_ms: 1,
25762                updated_at_unix_ms: 1,
25763                last_error: None,
25764            })
25765            .unwrap();
25766        let event = ControlEvent {
25767            sequence: 1,
25768            command_id: None,
25769            instance_id: address.session.instance_id,
25770            generation: address.session.generation,
25771            event: ControlEventKind::ProviderEvent {
25772                sequence: 1,
25773                source: gate4agent_types::ProviderSource {
25774                    family: AdapterFamily::PtySemantic,
25775                    binding: AdapterBinding::new(
25776                        gate4agent_types::AdapterId::new("claude").unwrap(),
25777                        "fixture/v1",
25778                        gate4agent_types::AdapterVerification::SyntheticFixture,
25779                    )
25780                    .unwrap(),
25781                },
25782                source_sequence: 1,
25783                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25784                    identity: gate4agent_types::ProviderSessionIdentity {
25785                        key: gate4agent_types::ProviderSessionKey::SessionId,
25786                        id: "provider-session".to_owned(),
25787                        transcript_path: None,
25788                    },
25789                },
25790            },
25791        };
25792
25793        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25794
25795        let record = shared.record(&record_id).unwrap();
25796        assert!(record.provider_session.is_none());
25797        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25798        assert_eq!(record.active_session.as_ref(), Some(&address));
25799    }
25800
25801    #[test]
25802    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25803        let secondary_root = temporary_workspace_root("identity-scope");
25804        std::fs::create_dir_all(&secondary_root).unwrap();
25805        let catalog = active_registry().unwrap();
25806        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25807        let primary = WorkspaceConfig::new(
25808            WorkspaceId::new("primary").unwrap(),
25809            std::env::current_dir().unwrap(),
25810        )
25811        .unwrap();
25812        let secondary = WorkspaceConfig::new(
25813            WorkspaceId::new("secondary").unwrap(),
25814            &secondary_root,
25815        )
25816        .unwrap();
25817        let shared = NodeShared::new(
25818            handle,
25819            "fixture-token".to_owned(),
25820            NodeId::new("node-1").unwrap(),
25821            vec![primary.clone(), secondary.clone()],
25822            vec![agent("claude")],
25823        );
25824        let identity = gate4agent_types::ProviderSessionIdentity {
25825            key: gate4agent_types::ProviderSessionKey::SessionId,
25826            id: "provider-session".to_owned(),
25827            transcript_path: None,
25828        };
25829        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25830        shared
25831            .insert_record(ManagedSessionRecord {
25832                record_id: dormant_id.clone(),
25833                display_name: "dormant".to_owned(),
25834                provider: agent("claude"),
25835                mode: SessionMode::Pty,
25836                state: ManagedSessionState::Dormant,
25837                workspace_id: primary.workspace_id,
25838                canonical_root: opaque_windows_path(primary.canonical_root),
25839                provider_session: Some(identity.clone()),
25840                active_session: None,
25841                environment_profile: None,
25842                bundle: None,
25843                context_id: None,
25844                context: None,
25845                exported_context: None,
25846                task_binding: None,
25847                created_at_unix_ms: 1,
25848                updated_at_unix_ms: 1,
25849                last_error: None,
25850            })
25851            .unwrap();
25852        let address = SessionAddress {
25853            workspace_id: secondary.workspace_id.clone(),
25854            session: SessionKey {
25855                instance_id: AgentInstanceId(91),
25856                generation: SessionGeneration(1),
25857            },
25858        };
25859        let current_id = SessionRecordId::new("sr-current").unwrap();
25860        shared
25861            .insert_record(ManagedSessionRecord {
25862                record_id: current_id.clone(),
25863                display_name: "current".to_owned(),
25864                provider: agent("claude"),
25865                mode: SessionMode::Pty,
25866                state: ManagedSessionState::IdentityPending,
25867                workspace_id: secondary.workspace_id,
25868                canonical_root: opaque_windows_path(secondary.canonical_root),
25869                provider_session: None,
25870                active_session: Some(address.clone()),
25871                environment_profile: None,
25872                bundle: None,
25873                context_id: None,
25874                context: None,
25875                exported_context: None,
25876                task_binding: None,
25877                created_at_unix_ms: 2,
25878                updated_at_unix_ms: 2,
25879                last_error: None,
25880            })
25881            .unwrap();
25882        shared.bind_managed_session(
25883            &address,
25884            current_id.clone(),
25885            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25886            None,
25887        );
25888        let event = ControlEvent {
25889            sequence: 1,
25890            command_id: None,
25891            instance_id: address.session.instance_id,
25892            generation: address.session.generation,
25893            event: ControlEventKind::ProviderEvent {
25894                sequence: 1,
25895                source: gate4agent_types::ProviderSource {
25896                    family: gate4agent_types::AdapterFamily::PtySemantic,
25897                    binding: gate4agent_types::AdapterBinding::new(
25898                        gate4agent_types::AdapterId::new("claude").unwrap(),
25899                        "fixture/v1",
25900                        gate4agent_types::AdapterVerification::SyntheticFixture,
25901                    )
25902                    .unwrap(),
25903                },
25904                source_sequence: 1,
25905                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25906                    identity,
25907                },
25908            },
25909        };
25910        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
25911
25912        let binding = shared
25913            .session_bindings
25914            .lock()
25915            .unwrap_or_else(|poisoned| poisoned.into_inner())
25916            .get(&address.session.instance_id)
25917            .cloned()
25918            .unwrap();
25919        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
25920        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25921        let current = shared.record(&current_id).unwrap();
25922        assert_eq!(current.state, ManagedSessionState::Unavailable);
25923        assert_eq!(current.active_session.as_ref(), Some(&address));
25924        assert_eq!(
25925            current.last_error.as_deref(),
25926            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25927        );
25928        std::fs::remove_dir_all(secondary_root).unwrap();
25929    }
25930
25931    #[tokio::test]
25932    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25933        let root = temporary_workspace_root("semantic-provider-session");
25934        std::fs::create_dir_all(&root).unwrap();
25935        let state_path = root.join("state-v1.json");
25936        let catalog = active_registry().unwrap();
25937        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25938        let workspace = WorkspaceConfig::new(
25939            WorkspaceId::new("primary").unwrap(),
25940            &root,
25941        )
25942        .unwrap();
25943        let node_id = NodeId::new("node-semantic-identity").unwrap();
25944        let shared = NodeShared::new_with_incarnation(
25945            handle,
25946            "fixture-token".to_owned(),
25947            node_id.clone(),
25948            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25949            vec![workspace.clone()],
25950            vec![agent("claude")],
25951            ProviderRuntimeStatuses::default(),
25952            None,
25953            Vec::new(),
25954            Vec::new(),
25955            SpawnProfileRegistry::default(),
25956            None,
25957            None,
25958            None,
25959            Some(state_path.clone()),
25960            Vec::new(),
25961            Vec::new(),
25962            Vec::new(),
25963            Vec::new(),
25964            None,
25965            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25966        );
25967        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25968            key: gate4agent_types::ProviderSessionKey::SessionId,
25969            id: "provider-session".to_owned(),
25970            transcript_path: None,
25971        };
25972        let durable_id = SessionRecordId::new("sr-durable").unwrap();
25973        shared
25974            .insert_record(ManagedSessionRecord {
25975                record_id: durable_id.clone(),
25976                display_name: "durable".to_owned(),
25977                provider: agent("claude"),
25978                mode: SessionMode::Pty,
25979                state: ManagedSessionState::Dormant,
25980                workspace_id: workspace.workspace_id.clone(),
25981                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25982                provider_session: Some(persisted_identity.clone()),
25983                active_session: None,
25984                environment_profile: None,
25985                bundle: None,
25986                context_id: None,
25987                context: None,
25988                exported_context: None,
25989                task_binding: None,
25990                created_at_unix_ms: 1,
25991                updated_at_unix_ms: 1,
25992                last_error: None,
25993            })
25994            .unwrap();
25995        let address = SessionAddress {
25996            workspace_id: workspace.workspace_id.clone(),
25997            session: SessionKey {
25998                instance_id: AgentInstanceId(92),
25999                generation: SessionGeneration(2),
26000            },
26001        };
26002        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26003        shared
26004            .insert_record(ManagedSessionRecord {
26005                record_id: pending_id.clone(),
26006                display_name: "pending".to_owned(),
26007                provider: agent("claude"),
26008                mode: SessionMode::Pty,
26009                state: ManagedSessionState::IdentityPending,
26010                workspace_id: workspace.workspace_id.clone(),
26011                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26012                provider_session: None,
26013                active_session: Some(address.clone()),
26014                environment_profile: None,
26015                bundle: None,
26016                context_id: None,
26017                context: None,
26018                exported_context: None,
26019                task_binding: None,
26020                created_at_unix_ms: 2,
26021                updated_at_unix_ms: 2,
26022                last_error: None,
26023            })
26024            .unwrap();
26025        shared.bind_managed_session(
26026            &address,
26027            pending_id.clone(),
26028            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26029            None,
26030        );
26031        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26032            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26033            ..persisted_identity
26034        };
26035        let event = ControlEvent {
26036            sequence: 1,
26037            command_id: None,
26038            instance_id: address.session.instance_id,
26039            generation: address.session.generation,
26040            event: ControlEventKind::ProviderEvent {
26041                sequence: 1,
26042                source: gate4agent_types::ProviderSource {
26043                    family: gate4agent_types::AdapterFamily::PtySemantic,
26044                    binding: gate4agent_types::AdapterBinding::new(
26045                        gate4agent_types::AdapterId::new("claude").unwrap(),
26046                        "fixture/v1",
26047                        gate4agent_types::AdapterVerification::SyntheticFixture,
26048                    )
26049                    .unwrap(),
26050                },
26051                source_sequence: 1,
26052                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26053                    identity: observed_identity.clone(),
26054                },
26055            },
26056        };
26057        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26058
26059        let snapshot = shared.snapshot();
26060        assert_eq!(snapshot.session_records.len(), 1);
26061        let live = &snapshot.session_records[0];
26062        assert_eq!(live.record_id, durable_id);
26063        assert_eq!(live.state, ManagedSessionState::Live);
26064        assert_eq!(live.active_session.as_ref(), Some(&address));
26065        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26066        let binding = shared
26067            .session_bindings
26068            .lock()
26069            .unwrap_or_else(|poisoned| poisoned.into_inner())
26070            .get(&address.session.instance_id)
26071            .cloned()
26072            .unwrap();
26073        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26074
26075        let mut duplicate = live.clone();
26076        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26077        duplicate.display_name = "duplicate".to_owned();
26078        duplicate.state = ManagedSessionState::Dormant;
26079        duplicate.active_session = None;
26080        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26081        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26082        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26083        assert_eq!(shared.snapshot().session_records.len(), 1);
26084
26085        let terminal_size = gate4agent_types::TerminalSize {
26086            rows: 24,
26087            columns: 80,
26088        };
26089        let (first_busy, second_busy) = tokio::join!(
26090            shared.resume_session_record(&durable_id, terminal_size, None),
26091            shared.resume_session_record(&durable_id, terminal_size, None),
26092        );
26093        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26094            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26095            assert_eq!(busy.message, "session-record-busy");
26096        }
26097
26098        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26099        assert_eq!(loaded.records.len(), 1);
26100        assert_eq!(loaded.records[0].record_id, durable_id);
26101        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26102        assert_eq!(
26103            loaded.records[0]
26104                .provider_session
26105                .as_ref()
26106                .unwrap()
26107                .transcript_path,
26108            None,
26109        );
26110        let catalog = active_registry().unwrap();
26111        let (reloaded_handle, _reloaded_runtime) =
26112            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26113        let reloaded = NodeShared::new_with_incarnation(
26114            reloaded_handle,
26115            "fixture-token".to_owned(),
26116            node_id,
26117            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26118            vec![workspace],
26119            vec![agent("claude")],
26120            ProviderRuntimeStatuses::default(),
26121            None,
26122            Vec::new(),
26123            Vec::new(),
26124            SpawnProfileRegistry::default(),
26125            None,
26126            None,
26127            None,
26128            None,
26129            loaded.records,
26130            loaded.managed_worktrees,
26131            loaded.managed_worktree_tombstones,
26132            loaded.materializations,
26133            loaded.warning,
26134            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26135        );
26136        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26137        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26138        std::fs::remove_dir_all(root).unwrap();
26139    }
26140
26141    #[tokio::test]
26142    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26143        let secondary_root = temporary_workspace_root("workspace-restore");
26144        let other_root = temporary_workspace_root("workspace-conflict");
26145        std::fs::create_dir_all(&secondary_root).unwrap();
26146        std::fs::create_dir_all(&other_root).unwrap();
26147        let catalog = active_registry().unwrap();
26148        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26149        let primary = WorkspaceConfig::new(
26150            WorkspaceId::new("primary").unwrap(),
26151            std::env::current_dir().unwrap(),
26152        )
26153        .unwrap();
26154        let secondary = WorkspaceConfig::new(
26155            WorkspaceId::new("secondary").unwrap(),
26156            &secondary_root,
26157        )
26158        .unwrap();
26159        let shared = NodeShared::new(
26160            handle,
26161            "fixture-token".to_owned(),
26162            NodeId::new("node-1").unwrap(),
26163            vec![primary, secondary.clone()],
26164            vec![agent("claude")],
26165        );
26166        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26167        shared
26168            .insert_record(ManagedSessionRecord {
26169                record_id: record_id.clone(),
26170                display_name: "workspace record".to_owned(),
26171                provider: agent("claude"),
26172                mode: SessionMode::Pty,
26173                state: ManagedSessionState::Dormant,
26174                workspace_id: secondary.workspace_id.clone(),
26175                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26176                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26177                    key: gate4agent_types::ProviderSessionKey::SessionId,
26178                    id: "provider-session".to_owned(),
26179                    transcript_path: None,
26180                }),
26181                active_session: None,
26182                environment_profile: None,
26183                bundle: None,
26184                context_id: None,
26185                context: None,
26186                exported_context: None,
26187                task_binding: None,
26188                created_at_unix_ms: 1,
26189                updated_at_unix_ms: 1,
26190                last_error: None,
26191            })
26192            .unwrap();
26193        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26194        let unavailable = shared.record(&record_id).unwrap();
26195        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26196        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26197
26198        let conflict = shared
26199            .register_workspace(
26200                secondary.workspace_id.clone(),
26201                other_root.to_string_lossy().into_owned(),
26202            )
26203            .await
26204            .unwrap_err();
26205        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26206        shared
26207            .register_workspace(
26208                secondary.workspace_id,
26209                secondary_root.to_string_lossy().into_owned(),
26210            )
26211            .await
26212            .unwrap();
26213        let restored = shared.record(&record_id).unwrap();
26214        assert_eq!(restored.state, ManagedSessionState::Dormant);
26215        assert!(restored.last_error.is_none());
26216        std::fs::remove_dir_all(secondary_root).unwrap();
26217        std::fs::remove_dir_all(other_root).unwrap();
26218    }
26219
26220    #[test]
26221    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26222        let error = validate_workspace_request_root(
26223            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26224        )
26225        .unwrap_err();
26226        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26227        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26228        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26229    }
26230
26231    #[test]
26232    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26233        let catalog = active_registry().unwrap();
26234        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26235        let workspace = WorkspaceConfig::new(
26236            WorkspaceId::new("primary").unwrap(),
26237            std::env::current_dir().unwrap(),
26238        )
26239        .unwrap();
26240        let shared = NodeShared::new(
26241            handle,
26242            "fixture-token".to_owned(),
26243            NodeId::new("node-1").unwrap(),
26244            vec![workspace],
26245            vec![agent("claude")],
26246        );
26247        let publishers = 4_u64;
26248        let events_per_publisher = 256_u64;
26249        let start = std::sync::Barrier::new(publishers as usize + 1);
26250        let finished = AtomicU64::new(0);
26251
26252        std::thread::scope(|scope| {
26253            for _ in 0..publishers {
26254                scope.spawn(|| {
26255                    start.wait();
26256                    for _ in 0..events_per_publisher {
26257                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26258                    }
26259                    finished.fetch_add(1, Ordering::Release);
26260                });
26261            }
26262            start.wait();
26263            while finished.load(Ordering::Acquire) != publishers {
26264                assert_resync_history_is_linearized(shared.resync(0));
26265                std::thread::yield_now();
26266            }
26267        });
26268        let response = shared.resync(0);
26269        assert_resync_history_is_linearized(response.clone());
26270        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26271            unreachable!("resync helper returned another response");
26272        };
26273        assert_eq!(event_sequence, publishers * events_per_publisher);
26274        assert_eq!(events.len() as u64, event_sequence);
26275    }
26276
26277    fn assert_resync_history_is_linearized(response: NodeResponse) {
26278        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26279            unreachable!("resync helper returned another response");
26280        };
26281        assert!(events.windows(2).all(|pair| {
26282            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26283        }));
26284        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26285    }
26286
26287    #[test]
26288    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26289        assert_eq!(
26290            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26291            r"C:\repo\worktree",
26292        );
26293        assert_eq!(
26294            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26295            r"\\server\share\repo",
26296        );
26297        assert!(validate_workspace_root(
26298            &WorkspaceId::new("network").unwrap(),
26299            r"\\server\share\repo",
26300        )
26301        .is_err());
26302        let workspace = WorkspaceConfig::new(
26303            WorkspaceId::new("primary").unwrap(),
26304            std::env::current_dir().unwrap(),
26305        )
26306        .unwrap();
26307        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26308    }
26309
26310    #[test]
26311    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26312        let catalog = active_registry().unwrap();
26313        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26314        let workspace_id = WorkspaceId::new("primary").unwrap();
26315        let workspace = WorkspaceConfig::new(
26316            workspace_id.clone(),
26317            std::env::current_dir().unwrap(),
26318        )
26319        .unwrap();
26320        let shared = NodeShared::new(
26321            handle,
26322            "fixture-token".to_owned(),
26323            NodeId::new("node-1").unwrap(),
26324            vec![workspace],
26325            vec![agent("claude")],
26326        );
26327        let instance_id = AgentInstanceId(17);
26328        let original = SessionAddress {
26329            workspace_id,
26330            session: SessionKey {
26331                instance_id,
26332                generation: SessionGeneration(3),
26333            },
26334        };
26335        shared.bind_session(
26336            instance_id,
26337            original.workspace_id.clone(),
26338            original.session.generation,
26339        );
26340        shared
26341            .arm_resume(
26342                &original,
26343                CommandId(41),
26344                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26345            )
26346            .unwrap();
26347
26348        shared.publish_control(ControlEvent {
26349            sequence: 1,
26350            command_id: None,
26351            instance_id,
26352            generation: SessionGeneration(4),
26353            event: ControlEventKind::Running { process_id: None },
26354        });
26355        assert_eq!(
26356            shared.address_for(instance_id, SessionGeneration(3)),
26357            Some(original.clone()),
26358        );
26359        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26360
26361        shared.publish_control(ControlEvent {
26362            sequence: 2,
26363            command_id: Some(CommandId(42)),
26364            instance_id,
26365            generation: SessionGeneration(3),
26366            event: ControlEventKind::CommandRejected {
26367                message: "unrelated command".to_owned(),
26368            },
26369        });
26370
26371        shared.publish_control(ControlEvent {
26372            sequence: 3,
26373            command_id: None,
26374            instance_id,
26375            generation: SessionGeneration(4),
26376            event: ControlEventKind::ResumeAuthorized {
26377                session: gate4agent_types::ResumeSessionSummary {
26378                    key: gate4agent_types::ProviderSessionKey::SessionId,
26379                    id: "fixture-session".to_owned(),
26380                },
26381            },
26382        });
26383        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26384        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26385        assert_eq!(rebound.workspace_id, original.workspace_id);
26386        let history = shared
26387            .history
26388            .lock()
26389            .unwrap_or_else(|poisoned| poisoned.into_inner());
26390        assert!(history.events.iter().any(|envelope| matches!(
26391            &envelope.event,
26392            NodeEvent::Control { address, event }
26393                if address == &rebound
26394                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26395        )));
26396    }
26397
26398    #[test]
26399    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26400        for agent in ["claude", "codex", "kimi"] {
26401            assert_eq!(
26402                prompt_framing(&AgentId::new(agent).unwrap()),
26403                PromptFraming::BracketedPaste
26404            );
26405        }
26406    }
26407
26408    fn generous_walk_deadline() -> Instant {
26409        Instant::now() + Duration::from_secs(30)
26410    }
26411
26412    #[test]
26413    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26414        let root = temporary_workspace_root("tree");
26415        std::fs::create_dir_all(root.join("src")).unwrap();
26416        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26417        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26418        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26419        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26420        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26421        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26422        // real repository's own build layout) that carries the Cache
26423        // Directory Tagging Specification marker cargo writes into every
26424        // `--target-dir` it creates: must be skipped even though its name
26425        // does not exactly match `target`.
26426        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26427        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26428        // A directory that merely *starts with* `target` but carries no
26429        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26430        // on the name alone, so this stays in the response.
26431        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26432        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26433
26434        let (entries, truncated, _budget) =
26435            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26436        assert!(!truncated);
26437        assert!(entries.iter().any(|entry| {
26438            entry.relative_path.as_utf8() == Some("src")
26439                && entry.kind == WorkspaceEntryKind::Directory
26440        }));
26441        assert!(entries.iter().any(|entry| {
26442            entry.relative_path.as_utf8() == Some("src/lib.rs")
26443                && entry.kind == WorkspaceEntryKind::File
26444        }));
26445        assert!(entries.iter().any(|entry| {
26446            entry.relative_path.as_utf8() == Some("target-notes")
26447                && entry.kind == WorkspaceEntryKind::Directory
26448        }));
26449        assert!(entries.iter().any(|entry| {
26450            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26451                && entry.kind == WorkspaceEntryKind::File
26452        }));
26453        assert!(entries.iter().all(|entry| {
26454            let path = entry.relative_path.as_utf8().unwrap();
26455            !path.starts_with(".git")
26456                && !(path == "target" || path.starts_with("target/"))
26457                && !(path == "target-a2" || path.starts_with("target-a2/"))
26458                && !path.starts_with("node_modules")
26459        }));
26460
26461        std::fs::remove_dir_all(&root).unwrap();
26462    }
26463
26464    #[test]
26465    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26466        let root = temporary_workspace_root("tree-entry-cap");
26467        std::fs::create_dir_all(&root).unwrap();
26468        for index in 0..20 {
26469            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26470        }
26471
26472        let (entries, truncated, budget) =
26473            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26474        assert!(truncated);
26475        assert!(budget.entry_cap_exceeded);
26476        assert!(!budget.time_budget_exceeded);
26477        assert!(entries.len() <= 5);
26478
26479        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26480            .expect("an entry-cap-truncated walk must report a truncation marker");
26481        assert!(inspection_truncation.walk_entry_cap_exceeded);
26482        assert!(!inspection_truncation.walk_time_budget_exceeded);
26483        assert!(!inspection_truncation.git_time_budget_exceeded);
26484        assert!(inspection_truncation.entries_visited >= 5);
26485
26486        std::fs::remove_dir_all(&root).unwrap();
26487    }
26488
26489    #[test]
26490    fn workspace_walk_within_budget_reports_no_truncation() {
26491        let root = temporary_workspace_root("tree-no-truncation");
26492        std::fs::create_dir_all(&root).unwrap();
26493        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26494
26495        let (_entries, truncated, budget) =
26496            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26497        assert!(!truncated);
26498        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26499
26500        std::fs::remove_dir_all(&root).unwrap();
26501    }
26502
26503    #[test]
26504    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26505        let root = temporary_workspace_root("tree-prefix-siblings");
26506        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26507        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26508        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26509        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26510
26511        let (entries, truncated, _budget) =
26512            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26513        assert!(!truncated);
26514        let paths = entries
26515            .iter()
26516            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26517            .collect::<Vec<_>>();
26518        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26519        assert_eq!(
26520            paths,
26521            vec![
26522                "gate4agent-c2".to_owned(),
26523                "gate4agent-c2-client".to_owned(),
26524                "gate4agent-c2-client/lib.rs".to_owned(),
26525                "gate4agent-c2/src".to_owned(),
26526                "gate4agent-c2/src/lib.rs".to_owned(),
26527            ],
26528        );
26529
26530        std::fs::remove_dir_all(&root).unwrap();
26531    }
26532
26533    #[test]
26534    fn git_parsers_cap_status_and_commit_results() {
26535        let mut status_output = Vec::new();
26536        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26537            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26538        }
26539        let mut snapshot = git_snapshot_for_parser();
26540        parse_git_status(&status_output, &mut snapshot);
26541        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26542        assert!(snapshot.truncated);
26543        assert_eq!(snapshot.status[0].index_status, " ");
26544        assert_eq!(snapshot.status[0].worktree_status, "M");
26545
26546        snapshot.truncated = false;
26547        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26548            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26549            .collect::<String>();
26550        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26551        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26552        assert!(snapshot.truncated);
26553    }
26554
26555    #[test]
26556    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26557        let mut snapshot = git_snapshot_for_parser();
26558        parse_git_status(
26559            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26560            &mut snapshot,
26561        );
26562
26563        assert!(!snapshot.truncated);
26564        assert_eq!(snapshot.status.len(), 3);
26565        assert_eq!(snapshot.status[0].index_status, "R");
26566        assert_eq!(snapshot.status[0].worktree_status, " ");
26567        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26568        assert_eq!(
26569            snapshot.status[0]
26570                .previous_path
26571                .as_ref()
26572                .and_then(RepositoryPath::as_utf8),
26573            Some("src/previous name.rs"),
26574        );
26575        assert_eq!(snapshot.status[1].index_status, " ");
26576        assert_eq!(snapshot.status[1].worktree_status, "C");
26577        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26578        assert_eq!(
26579            snapshot.status[1]
26580                .previous_path
26581                .as_ref()
26582                .and_then(RepositoryPath::as_utf8),
26583            Some("src/original.rs"),
26584        );
26585        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26586        assert!(snapshot.status[2].previous_path.is_none());
26587    }
26588
26589    #[test]
26590    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26591        let mut snapshot = git_snapshot_for_parser();
26592        parse_git_status(
26593            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26594            &mut snapshot,
26595        );
26596
26597        assert!(snapshot.truncated);
26598        assert_eq!(snapshot.status.len(), 1);
26599        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26600        assert!(snapshot.status[0].previous_path.is_none());
26601    }
26602
26603    #[tokio::test]
26604    async fn observer_can_browse_host_directories_without_controller() {
26605        let root = temporary_workspace_root("host-directory-observer");
26606        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26607        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26608        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26609        let workspace_id = WorkspaceId::new("primary").unwrap();
26610        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26611        let catalog = active_registry().unwrap();
26612        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26613        let shared = NodeShared::new(
26614            handle,
26615            "fixture-token".to_owned(),
26616            NodeId::new("node-1").unwrap(),
26617            vec![workspace],
26618            vec![agent("claude")],
26619        );
26620        let request = NodeRequest::BrowseHostDirectories {
26621            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26622            after: None,
26623        };
26624        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26625            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26626        }));
26627        assert!(!request_uses_unnegotiated_capability(
26628            &request,
26629            &baseline_capabilities().unwrap(),
26630        ));
26631        assert!(request_uses_unnegotiated_capability(&request, &[]));
26632
26633        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26634            .await
26635            .unwrap();
26636        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26637            panic!("host directory browse returned another response");
26638        };
26639        assert_eq!(
26640            listing
26641                .entries
26642                .iter()
26643                .map(|entry| entry.display_name.as_str())
26644                .collect::<Vec<_>>(),
26645            vec!["directory-a", "directory-b"],
26646        );
26647        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26648        assert!(!listing.incomplete);
26649        std::fs::remove_dir_all(root).unwrap();
26650    }
26651
26652    #[tokio::test]
26653    async fn observer_can_inspect_only_a_registered_workspace() {
26654        let root = temporary_workspace_root("observer");
26655        std::fs::create_dir_all(root.join("src")).unwrap();
26656        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26657        let workspace_id = WorkspaceId::new("primary").unwrap();
26658        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26659        let catalog = active_registry().unwrap();
26660        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26661        let shared = NodeShared::new(
26662            handle,
26663            "fixture-token".to_owned(),
26664            NodeId::new("node-1").unwrap(),
26665            vec![workspace],
26666            vec![agent("claude")],
26667        );
26668
26669        let response = process_request_inner(
26670            &shared,
26671            77,
26672            ClientRole::Observer,
26673            NodeRequest::InspectWorkspace {
26674                workspace_id: workspace_id.clone(),
26675            },
26676        )
26677        .await
26678        .unwrap();
26679        let NodeResponse::WorkspaceInspected { inspection } = response else {
26680            panic!("workspace inspection returned another response");
26681        };
26682        assert_eq!(inspection.workspace_id, workspace_id);
26683        assert!(inspection.entries.iter().any(|entry| {
26684            entry.relative_path.as_utf8() == Some("src/main.rs")
26685        }));
26686
26687        let unknown = process_request_inner(
26688            &shared,
26689            77,
26690            ClientRole::Observer,
26691            NodeRequest::InspectWorkspace {
26692                workspace_id: WorkspaceId::new("unknown").unwrap(),
26693            },
26694        )
26695        .await
26696        .unwrap_err();
26697        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26698        std::fs::remove_dir_all(&root).unwrap();
26699    }
26700
26701    #[test]
26702    fn context_pack_source_accepts_only_running_or_successful_exit() {
26703        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26704        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26705            exit_code: Some(0),
26706        }));
26707        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26708            exit_code: Some(1),
26709        }));
26710        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26711            exit_code: None,
26712        }));
26713        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26714            message: "provider failed".to_owned(),
26715        }));
26716        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26717        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26718        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26719    }
26720
26721    #[test]
26722    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26723        let git = git_snapshot_for_parser();
26724        let first = ContextPackRepositoryObservation {
26725            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26726            git: git.clone(),
26727            files: Vec::new(),
26728        };
26729        let second = ContextPackRepositoryObservation {
26730            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26731            git,
26732            files: Vec::new(),
26733        };
26734
26735        let error = stable_context_pack_repository(first, second).unwrap_err();
26736
26737        assert_eq!(
26738            error.code,
26739            NodeFailureCode::ContextPackMaterializationFailed,
26740        );
26741    }
26742
26743    #[test]
26744    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26745        let git = git_snapshot_for_parser();
26746        let first = ContextPackRepositoryObservation {
26747            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26748            git: git.clone(),
26749            files: vec![ContextPackRepositoryFileSource::utf8(
26750                "README.md",
26751                "first\n".to_owned(),
26752                6,
26753            )],
26754        };
26755        let second = ContextPackRepositoryObservation {
26756            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26757            git,
26758            files: vec![ContextPackRepositoryFileSource::utf8(
26759                "README.md",
26760                "other\n".to_owned(),
26761                6,
26762            )],
26763        };
26764
26765        let error = stable_context_pack_repository(first, second).unwrap_err();
26766
26767        assert_eq!(
26768            error.code,
26769            NodeFailureCode::ContextPackMaterializationFailed,
26770        );
26771    }
26772
26773    #[tokio::test]
26774    async fn native_session_catalog_zero_active_session_fixture() {
26775        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26776        let history_root = temporary_workspace_root("native-catalog-history");
26777        let outside_root = temporary_workspace_root("native-catalog-outside");
26778        std::fs::create_dir_all(&workspace_root).unwrap();
26779        std::fs::create_dir_all(&history_root).unwrap();
26780        std::fs::create_dir_all(&outside_root).unwrap();
26781        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26782            [
26783                serde_json::json!({
26784                    "type": "user",
26785                    "sessionId": session_id,
26786                    "cwd": cwd.to_str().unwrap(),
26787                    "message": { "content": "raw user text must not cross the catalog wire" }
26788                }),
26789                serde_json::json!({
26790                    "type": "assistant",
26791                    "sessionId": session_id,
26792                    "cwd": cwd.to_str().unwrap(),
26793                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26794                }),
26795                serde_json::json!({
26796                    "type": "ai-title",
26797                    "sessionId": session_id,
26798                    "aiTitle": title
26799                }),
26800            ]
26801            .into_iter()
26802            .map(|record| record.to_string())
26803            .collect::<Vec<_>>()
26804            .join("\n")
26805        };
26806        std::fs::write(
26807            history_root.join("stable-session.jsonl"),
26808            transcript("stable-session", &workspace_root, "Workspace review"),
26809        )
26810        .unwrap();
26811        std::fs::write(
26812            history_root.join("outside-session.jsonl"),
26813            transcript("outside-session", &outside_root, "Outside review"),
26814        )
26815        .unwrap();
26816        let history = NativeHistoryConfig::new(vec![
26817            NativeHistoryRoot::new(
26818                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26819                HistorySourceLayout::SingleNdjson,
26820                history_root.clone(),
26821            )
26822            .unwrap(),
26823        ])
26824        .unwrap();
26825        let catalog = active_registry().unwrap();
26826        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26827        assert!(handle.snapshot().sessions.is_empty());
26828        let workspace_id = WorkspaceId::new("primary").unwrap();
26829        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26830        let mut shared = NodeShared::new(
26831            handle,
26832            "fixture-token".to_owned(),
26833            NodeId::new("node-native-catalog").unwrap(),
26834            vec![workspace],
26835            vec![agent("claude")],
26836        );
26837        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26838            NativeSessionCatalogAuthority::new(history),
26839        )));
26840        let route = NativeSessionCatalogRoute::workspace(
26841            workspace_id.clone(),
26842            agent("claude"),
26843        );
26844        let request = NodeRequest::CatalogNativeSessions {
26845            route: route.clone(),
26846            limit: 2,
26847        };
26848        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26849            .await
26850            .unwrap_err();
26851        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26852        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26853            .await
26854            .unwrap();
26855        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26856            panic!("native catalog returned a different response");
26857        };
26858        let summary = summary.expect("node must emit native session catalog summary metadata");
26859        assert_eq!(entries.len(), 1);
26860        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26861        assert_eq!(entries[0].external_group, None);
26862        assert_eq!(entries[0].record_id, None);
26863        assert_eq!(entries[0].title, None);
26864        assert_eq!(entries[0].model, None);
26865        assert_eq!(entries[0].message_count, 0);
26866        let encoded_entries = serde_json::to_string(&entries).unwrap();
26867        assert!(!encoded_entries.contains("stable-session"));
26868        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26869        let page_request = NodeRequest::PageNativeSessions {
26870            route: route.clone(),
26871            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26872            catalog_revision: summary.catalog_revision,
26873            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26874            after_selection_id: None,
26875            limit: 2,
26876        };
26877        let observer = process_request_inner(
26878            &shared,
26879            7,
26880            ClientRole::Observer,
26881            page_request.clone(),
26882        )
26883        .await
26884        .unwrap_err();
26885        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26886        let page_response = process_request_inner(
26887            &shared,
26888            7,
26889            ClientRole::Operator,
26890            page_request.clone(),
26891        )
26892        .await
26893        .unwrap();
26894        assert!(matches!(
26895            page_response,
26896            NodeResponse::NativeSessionsPaged { page, .. }
26897                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26898        ));
26899        let stale = process_request_inner(
26900            &shared,
26901            7,
26902            ClientRole::Operator,
26903            NodeRequest::PageNativeSessions {
26904                route: route.clone(),
26905                window: crate::protocol::NativeSessionCatalogWindow::Recent,
26906                catalog_revision: summary.catalog_revision.wrapping_add(1),
26907                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26908                after_selection_id: None,
26909                limit: 2,
26910            },
26911        )
26912        .await
26913        .unwrap_err();
26914        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26915        let selection = NativeSessionSelection {
26916            route: route.clone(),
26917            catalog_revision: summary.catalog_revision,
26918            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26919            selection_id: entries[0].selection_id.clone(),
26920        };
26921        let preview_response = process_request_inner(
26922            &shared,
26923            7,
26924            ClientRole::Operator,
26925            NodeRequest::PreviewNativeSession {
26926                selection: selection.clone(),
26927                message_limit: 1,
26928            },
26929        )
26930        .await
26931        .unwrap();
26932        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26933            panic!("native preview returned a different response");
26934        };
26935        assert_eq!(preview.messages.len(), 1);
26936        assert!(preview.message_count_exact);
26937        assert_eq!(preview.messages[0].text, "raw answer");
26938        assert!(preview.truncated);
26939
26940        let index_request = NodeRequest::IndexNativeSession {
26941            selection: selection.clone(),
26942            display_name: "Saved Claude".to_owned(),
26943        };
26944        let observer = process_request_inner(
26945            &shared,
26946            7,
26947            ClientRole::Observer,
26948            index_request.clone(),
26949        )
26950        .await
26951        .unwrap_err();
26952        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26953        let without_controller = process_request_inner(
26954            &shared,
26955            7,
26956            ClientRole::Operator,
26957            index_request.clone(),
26958        )
26959        .await
26960        .unwrap_err();
26961        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26962        let acquired = process_request_inner(
26963            &shared,
26964            7,
26965            ClientRole::Operator,
26966            NodeRequest::AcquireController { lease_ms: 5_000 },
26967        )
26968        .await
26969        .unwrap();
26970        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26971        let indexed = process_request_inner(
26972            &shared,
26973            7,
26974            ClientRole::Operator,
26975            index_request,
26976        )
26977        .await
26978        .unwrap();
26979        assert!(indexed.native_session_index_contract_is_valid());
26980        let NodeResponse::NativeSessionIndexed {
26981            selection: echoed_selection,
26982            record,
26983        } = indexed else {
26984            panic!("native session index returned a different response");
26985        };
26986        assert_eq!(echoed_selection, selection);
26987        let recataloged = process_request_inner(
26988            &shared,
26989            7,
26990            ClientRole::Operator,
26991            NodeRequest::CatalogNativeSessions {
26992                route,
26993                limit: 2,
26994            },
26995        )
26996        .await
26997        .unwrap();
26998        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26999            panic!("native catalog returned a different response after indexing");
27000        };
27001        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27002
27003        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27004        let external_catalog = process_request_inner(
27005            &shared,
27006            7,
27007            ClientRole::Operator,
27008            NodeRequest::CatalogNativeSessions {
27009                route: unregistered_route.clone(),
27010                limit: 2,
27011            },
27012        )
27013        .await
27014        .unwrap();
27015        let external_json = serde_json::to_string(&external_catalog).unwrap();
27016        assert!(!external_json.contains("outside-session"));
27017        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27018        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27019        assert!(!external_json.contains("\"session_id\""));
27020        assert!(!external_json.contains("\"cwd\""));
27021        assert!(!external_json.contains("\"path\""));
27022        let NodeResponse::NativeSessionsCataloged {
27023            route: echoed_route,
27024            entries: external_entries,
27025            summary: Some(external_summary),
27026        } = external_catalog
27027        else {
27028            panic!("unregistered native catalog returned a different response");
27029        };
27030        assert_eq!(echoed_route, unregistered_route);
27031        assert_eq!(external_entries.len(), 1);
27032        assert_eq!(external_entries[0].record_id, None);
27033        let external_group = external_entries[0]
27034            .external_group
27035            .as_ref()
27036            .expect("unregistered session must have an external group");
27037        assert_eq!(
27038            external_group.kind,
27039            gate4agent_types::NativeSessionExternalGroupKind::Project,
27040        );
27041        assert!(external_group.group_id.starts_with("external-"));
27042        assert_eq!(
27043            external_group.display_name,
27044            outside_root.file_name().unwrap().to_string_lossy(),
27045        );
27046        let external_selection = NativeSessionSelection {
27047            route: unregistered_route,
27048            catalog_revision: external_summary.catalog_revision,
27049            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27050            selection_id: external_entries[0].selection_id.clone(),
27051        };
27052        let external_preview = process_request_inner(
27053            &shared,
27054            7,
27055            ClientRole::Operator,
27056            NodeRequest::PreviewNativeSession {
27057                selection: external_selection.clone(),
27058                message_limit: 1,
27059            },
27060        )
27061        .await
27062        .unwrap();
27063        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27064        assert!(!external_preview_json.contains("outside-session"));
27065        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27066        assert!(!external_preview_json.contains("\"session_id\""));
27067        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27068            panic!("unregistered native preview returned a different response");
27069        };
27070        assert_eq!(preview.messages.len(), 1);
27071        let external_index = process_request_inner(
27072            &shared,
27073            7,
27074            ClientRole::Operator,
27075            NodeRequest::IndexNativeSession {
27076                selection: external_selection,
27077                display_name: "External Claude".to_owned(),
27078            },
27079        )
27080        .await
27081        .unwrap_err();
27082        assert_eq!(
27083            external_index.code,
27084            NodeFailureCode::WorkspaceRegistrationRequired,
27085        );
27086        let record_response = process_request_inner(
27087            &shared,
27088            7,
27089            ClientRole::Operator,
27090            NodeRequest::PreviewSessionRecord {
27091                record_id: record.record_id.clone(),
27092                message_limit: 2,
27093            },
27094        )
27095        .await
27096        .unwrap();
27097        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27098            panic!("record preview returned a different response");
27099        };
27100        assert_eq!(record_id, record.record_id);
27101        assert_eq!(preview.messages.len(), 2);
27102        let encoded = serde_json::to_string(&preview).unwrap();
27103        assert!(!encoded.contains("stable-session"));
27104        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27105        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27106            panic!("record preview history summary returned a different response");
27107        };
27108        let summaries = events
27109            .iter()
27110            .filter_map(|envelope| match &envelope.event {
27111                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27112                    if record_id == &record.record_id =>
27113                {
27114                    Some(*summary)
27115                }
27116                _ => None,
27117            })
27118            .collect::<Vec<_>>();
27119        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27120        let summary_json = serde_json::to_string(&summaries).unwrap();
27121        for private in [
27122            "stable-session",
27123            "raw user text must not cross the catalog wire",
27124            "raw answer",
27125            "Workspace review",
27126            "claude-sonnet",
27127        ] {
27128            assert!(!summary_json.contains(private), "{private}");
27129        }
27130        let identity = record.provider_session.clone().unwrap();
27131        assert_eq!(
27132            shared.revalidate_session_record_preview(&record, &identity),
27133            Ok(())
27134        );
27135        shared.remove_record_memory(&record.record_id);
27136        assert_eq!(
27137            shared
27138                .revalidate_session_record_preview(&record, &identity)
27139                .unwrap_err()
27140                .code,
27141            NodeFailureCode::UnknownSessionRecord
27142        );
27143        let released = process_request_inner(
27144            &shared,
27145            7,
27146            ClientRole::Operator,
27147            NodeRequest::ReleaseController,
27148        )
27149        .await
27150        .unwrap();
27151        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27152        assert!(shared.controller_state().is_none());
27153        std::fs::remove_dir_all(workspace_root).unwrap();
27154        std::fs::remove_dir_all(history_root).unwrap();
27155        std::fs::remove_dir_all(outside_root).unwrap();
27156    }
27157
27158    #[tokio::test]
27159    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27160        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27161        std::fs::create_dir_all(&history_root).unwrap();
27162        let history = NativeHistoryConfig::new(vec![
27163            NativeHistoryRoot::new(
27164                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27165                HistorySourceLayout::SingleNdjson,
27166                history_root.clone(),
27167            )
27168            .unwrap(),
27169        ])
27170        .unwrap();
27171        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27172        let held = catalog.lock().unwrap();
27173        let result = timeout(
27174            Duration::from_secs(1),
27175            run_native_session_catalog_operation(
27176                Arc::clone(&catalog),
27177                "native session catalog",
27178                |_| Ok::<(), std::convert::Infallible>(()),
27179            ),
27180        )
27181        .await
27182        .expect("concurrent native catalog request must not wait for the operation deadline")
27183        .unwrap_err();
27184        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27185        drop(held);
27186        std::fs::remove_dir_all(history_root).unwrap();
27187    }
27188
27189    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27190        let unique = std::time::SystemTime::now()
27191            .duration_since(std::time::UNIX_EPOCH)
27192            .unwrap()
27193            .as_nanos();
27194        std::env::temp_dir().join(format!(
27195            "gate4agent-node-{label}-{}-{unique}",
27196            std::process::id(),
27197        ))
27198    }
27199
27200    fn git_snapshot_for_parser() -> GitSnapshot {
27201        GitSnapshot {
27202            is_repository: true,
27203            branch: Some("main".to_owned()),
27204            status: Vec::new(),
27205            recent_commits: Vec::new(),
27206            worktrees: Vec::new(),
27207            managed_worktree: None,
27208            truncated: false,
27209            diagnostic: None,
27210        }
27211    }
27212
27213    #[tokio::test]
27214    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27215        let root = temporary_workspace_root("managed-git-scope");
27216        std::fs::create_dir_all(&root).unwrap();
27217        run_test_git(&root, &["init"]);
27218        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27219        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27220        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27221        run_test_git(&root, &["add", "seed.txt"]);
27222        run_test_git(&root, &["commit", "-m", "seed"]);
27223        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27224
27225        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27226        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27227        let catalog = active_registry().unwrap();
27228        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27229        let shared = NodeShared::new(
27230            handle,
27231            "fixture-token".to_owned(),
27232            NodeId::new("node-1").unwrap(),
27233            vec![workspace],
27234            vec![agent("claude")],
27235        );
27236        let mut lease = ManagedWorktreeLeaseRecord {
27237            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27238            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27239            workspace_id: workspace_id.clone(),
27240            profile_id: WorktreeProfileId::new("default").unwrap(),
27241            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27242            target_root: root.to_string_lossy().into_owned(),
27243            branch: "gate4agent/a".to_owned(),
27244            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27245            expected_head: None,
27246            retention: ManagedWorktreeRetention::Retain,
27247            state: ManagedWorktreeLeaseState::InUse,
27248            session_holders: vec![ManagedWorktreeSessionHolder {
27249                incarnation_id: shared.incarnation_id,
27250                instance_id: AgentInstanceId(7),
27251                generation: SessionGeneration(1),
27252            }],
27253            record_holders: Vec::new(),
27254            cleanup_failure: None,
27255            created_at_unix_ms: 1,
27256            updated_at_unix_ms: 1,
27257        };
27258        *shared
27259            .managed_worktrees
27260            .lock()
27261            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27262            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27263
27264        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27265        let scope = inspection.git.managed_worktree.unwrap();
27266        assert_eq!(scope.lease_id, lease.lease_id);
27267        assert_eq!(scope.branch, "gate4agent/a");
27268        assert_eq!(scope.active_session_count, 1);
27269
27270        lease.branch = "gate4agent/b".to_owned();
27271        *shared
27272            .managed_worktrees
27273            .lock()
27274            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27275            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27276        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27277        assert!(mismatch.git.managed_worktree.is_none());
27278        std::fs::remove_dir_all(root).unwrap();
27279    }
27280
27281    #[tokio::test]
27282    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27283        let root = temporary_workspace_root("workspace-file-write-controller");
27284        std::fs::create_dir_all(root.join("src")).unwrap();
27285        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27286        let workspace_id = WorkspaceId::new("primary").unwrap();
27287        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27288        let catalog = active_registry().unwrap();
27289        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27290        let shared = NodeShared::new(
27291            handle,
27292            "fixture-token".to_owned(),
27293            NodeId::new("node-1").unwrap(),
27294            vec![workspace],
27295            vec![agent("claude")],
27296        );
27297        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27298        let expected_revision = workspace_file_revision(b"old\n");
27299        let request = NodeRequest::WriteWorkspaceFile {
27300            workspace_id: workspace_id.clone(),
27301            path: path.clone(),
27302            expected_revision: expected_revision.clone(),
27303            text: "new\n".to_owned(),
27304        };
27305        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27306            .await
27307            .unwrap_err();
27308        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27309        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27310            .await
27311            .unwrap_err();
27312        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27313        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27314        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27315        let NodeResponse::WorkspaceFileWritten { file } = response else {
27316            panic!("workspace file write returned another response");
27317        };
27318        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27319        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27320        let stale = process_request_inner(
27321            &shared,
27322            77,
27323            ClientRole::Operator,
27324            NodeRequest::WriteWorkspaceFile {
27325                workspace_id,
27326                path,
27327                expected_revision,
27328                text: "lost update\n".to_owned(),
27329            },
27330        )
27331        .await
27332        .unwrap_err();
27333        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27334        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27335        std::fs::remove_dir_all(root).unwrap();
27336    }
27337
27338    #[tokio::test]
27339    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27340        let root = temporary_workspace_root("workspace-entry-create-controller");
27341        std::fs::create_dir_all(root.join("src")).unwrap();
27342        let workspace_id = WorkspaceId::new("primary").unwrap();
27343        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27344        let catalog = active_registry().unwrap();
27345        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27346        let shared = NodeShared::new(
27347            handle,
27348            "fixture-token".to_owned(),
27349            NodeId::new("node-1").unwrap(),
27350            vec![workspace],
27351            vec![agent("claude")],
27352        );
27353        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27354        let request = NodeRequest::CreateWorkspaceFile {
27355            workspace_id: workspace_id.clone(),
27356            path: path.clone(),
27357        };
27358        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27359            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27360        }));
27361        assert!(!request_uses_unnegotiated_capability(
27362            &request,
27363            &baseline_capabilities().unwrap(),
27364        ));
27365        assert!(request_uses_unnegotiated_capability(&request, &[]));
27366        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27367            .await
27368            .unwrap_err();
27369        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27370        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27371            .await
27372            .unwrap_err();
27373        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27374        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27375
27376        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27377            .await
27378            .unwrap();
27379        let NodeResponse::WorkspaceFileCreated { file } = response else {
27380            panic!("workspace file create returned another response");
27381        };
27382        assert_eq!(file.workspace_id, workspace_id);
27383        assert_eq!(file.path, path);
27384        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27385        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27386
27387        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27388            .await
27389            .unwrap_err();
27390        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27391
27392        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27393        let response = process_request_inner(
27394            &shared,
27395            77,
27396            ClientRole::Operator,
27397            NodeRequest::CreateWorkspaceDirectory {
27398                workspace_id: workspace_id.clone(),
27399                path: directory_path.clone(),
27400            },
27401        )
27402        .await
27403        .unwrap();
27404        assert_eq!(
27405            response,
27406            NodeResponse::WorkspaceDirectoryCreated {
27407                workspace_id: workspace_id.clone(),
27408                entry: WorkspaceEntry {
27409                    relative_path: directory_path,
27410                    kind: WorkspaceEntryKind::Directory,
27411                },
27412            },
27413        );
27414        assert!(root.join("src/nested").is_dir());
27415
27416        let missing_parent = process_request_inner(
27417            &shared,
27418            77,
27419            ClientRole::Operator,
27420            NodeRequest::CreateWorkspaceFile {
27421                workspace_id: workspace_id.clone(),
27422                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27423            },
27424        )
27425        .await
27426        .unwrap_err();
27427        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27428        let non_directory_parent = process_request_inner(
27429            &shared,
27430            77,
27431            ClientRole::Operator,
27432            NodeRequest::CreateWorkspaceFile {
27433                workspace_id,
27434                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27435            },
27436        )
27437        .await
27438        .unwrap_err();
27439        assert_eq!(
27440            non_directory_parent.code,
27441            NodeFailureCode::RepositoryParentNotDirectory,
27442        );
27443        std::fs::remove_dir_all(root).unwrap();
27444    }
27445
27446    #[tokio::test]
27447    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27448        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27449        std::fs::create_dir_all(root.join("src")).unwrap();
27450        #[cfg(windows)]
27451        let root = std::fs::canonicalize(root).unwrap();
27452        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27453        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27454        let barrier = Arc::new(std::sync::Barrier::new(2));
27455        let done = Arc::new(AtomicBool::new(false));
27456        let task_commit_state = Arc::clone(&commit_state);
27457        let task_barrier = Arc::clone(&barrier);
27458        let task_done = Arc::clone(&done);
27459        let task_root = root.clone();
27460        let task = tokio::task::spawn_blocking(move || {
27461            task_barrier.wait();
27462            let result = create_workspace_file_on_disk(
27463                &task_root,
27464                &path,
27465                &task_commit_state,
27466            );
27467            task_done.store(true, Ordering::Release);
27468            result
27469        });
27470
27471        let error = settle_workspace_entry_create(
27472            task,
27473            Arc::clone(&commit_state),
27474            Duration::from_millis(10),
27475            "fixture deadline",
27476            "fixture task failed",
27477        )
27478        .await
27479        .unwrap_err();
27480        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27481        assert_eq!(
27482            commit_state.load(Ordering::Acquire),
27483            WORKSPACE_ENTRY_CREATE_CANCELED,
27484        );
27485
27486        barrier.wait();
27487        timeout(Duration::from_secs(1), async {
27488            while !done.load(Ordering::Acquire) {
27489                tokio::task::yield_now().await;
27490            }
27491        })
27492        .await
27493        .expect("canceled blocking create did not settle");
27494        assert!(!root.join("src/late.rs").exists());
27495        std::fs::remove_dir_all(root).unwrap();
27496    }
27497
27498}