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            if let Some(door) = crate::door::door_listen_addr() {
3690                env_entries.push((
3691                    OsString::from("HATCHERY_NODE_DOOR"),
3692                    OsString::from(door.to_string()),
3693                ));
3694                // Same key shape as the session browser (`pty-{workspace}-{instance}`).
3695                let session_key = format!(
3696                    "pty-{}-{}",
3697                    address.workspace_id.as_str(),
3698                    address.session.instance_id.0
3699                );
3700                env_entries.push((
3701                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3702                    OsString::from(session_key),
3703                ));
3704            }
3705            let overlay = NativeMcpServerLaunchOverlay::new(
3706                provider.clone(),
3707                launch.server_name.as_str(),
3708                prepared.helper_program().as_os_str().to_os_string(),
3709                launch.args.iter().map(OsString::from).collect(),
3710                env_entries,
3711                launch.scrub_env.iter().map(OsString::from).collect(),
3712            ).map_err(|_| failure(
3713                NodeFailureCode::HarnessMcpUnavailable,
3714                "harness MCP launch overlay is unavailable",
3715            ))?;
3716            control
3717                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3718                .map_err(|_| failure(
3719                    NodeFailureCode::HarnessMcpUnavailable,
3720                    "harness MCP launch overlay could not be installed",
3721                ))?;
3722            // State-change line: the overlay is installed and will be read
3723            // back by the PTY child's environment or, for ACP,
3724            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3725            // time -- no secrets here, only which instance/provider/mode
3726            // now carries a harness-MCP door.
3727            tracing::info!(
3728                node_id = %self.node_id,
3729                instance_id = ?instance_id,
3730                provider = %provider,
3731                mode = ?mode,
3732                "harness MCP overlay installed for spawn",
3733            );
3734            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3735        } else {
3736            None
3737        };
3738        let prepared_materialization = self.prepare_session_materialization(
3739            &address,
3740            &provider,
3741            mode,
3742            environment_profile,
3743            bundle,
3744            context,
3745            managed_authority.cloned(),
3746        )?;
3747        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3748            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3749            None => (None, None),
3750        };
3751        let materialization_id = materialization_guard
3752            .as_ref()
3753            .and_then(SessionMaterializationGuard::id)
3754            .cloned();
3755        let mut environment_selection = SpawnPreparer::select_environment(self, 
3756            instance_id,
3757            &provider,
3758            mode,
3759            environment_profile,
3760        )?;
3761        // Station network overlays: Codex `-c network_access` and/or Claude
3762        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3763        let provider_home = materialization_guard
3764            .as_ref()
3765            .and_then(SessionMaterializationGuard::provider_home);
3766        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3767            &provider,
3768            mode,
3769            approval_level,
3770            network_allowlist,
3771            provider_home,
3772        )?;
3773        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3774            (prepared, args) if args.is_empty() => prepared,
3775            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3776                overlay.append_extra_args(args).map_err(|_| {
3777                    failure(
3778                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3779                        "network allowlist overlay could not merge with instance launch args",
3780                    )
3781                })?;
3782                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3783            }
3784            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3785                Some(PreparedNativeLaunchOverlay::Instance(
3786                    overlay
3787                        .into_instance_with_extra_args(args)
3788                        .map_err(|_| {
3789                            failure(
3790                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3791                                "network allowlist overlay could not merge with environment overlay",
3792                            )
3793                        })?,
3794                ))
3795            }
3796            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3797                NativeInstanceLaunchOverlay::new(
3798                    provider.clone(),
3799                    match mode {
3800                        SessionMode::Pty => TransportKind::Pty,
3801                        SessionMode::Acp => TransportKind::Acp,
3802                        SessionMode::Inline => TransportKind::Pipe,
3803                    },
3804                    Vec::new(),
3805                    args,
3806                )
3807                .map_err(|_| {
3808                    failure(
3809                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3810                        "network allowlist overlay is invalid for this transport",
3811                    )
3812                })?,
3813            )),
3814        };
3815        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3816        // overlay or appended to the overlay the spawn already carries.
3817        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3818            (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3819                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3820                match accounts.provision(m4a_provider, &session_key) {
3821                    Ok(account) => {
3822                        let identity = account.env_mutations();
3823                        let merged = match environment_overlay {
3824                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3825                                .map(PreparedNativeLaunchOverlay::Instance),
3826                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3827                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3828                            }
3829                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3830                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3831                            }
3832                        }
3833                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3834                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3835                        self.m4a_live
3836                            .lock()
3837                            .unwrap_or_else(|p| p.into_inner())
3838                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3839                        Some(merged)
3840                    }
3841                    Err(error) => {
3842                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3843                        environment_overlay
3844                    }
3845                }
3846            }
3847            (SessionMode::Pty, Some(_), None) => {
3848                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3849                environment_overlay
3850            }
3851            _ => environment_overlay,
3852        };
3853        let mut instance_overlay = environment_overlay
3854            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3855            .transpose()?
3856            .flatten();
3857        Ok(PreparedSpawn {
3858            instance_overlay,
3859            environment_selection,
3860            materialization_guard,
3861            harness_mcp_overlay,
3862            #[cfg(feature = "dig2-station-probe")]
3863            browser_station_lease_guard,
3864            materialization_id,
3865        })
3866    }
3867}
3868
3869struct SessionRecords {
3870    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3871}
3872
3873struct SpawnIdempotencyCache {
3874    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3875}
3876
3877struct SpawnIdempotencyEntry {
3878    value: SpawnIdempotencyValue,
3879    expires_at: Instant,
3880}
3881
3882enum SpawnIdempotencyValue {
3883    Standard {
3884        spec: SpawnSpec,
3885        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3886    },
3887    Managed {
3888        request: ManagedWorktreeSpawnRequest,
3889        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3890    },
3891    HarnessMcp,
3892}
3893
3894enum DurableManagedSpawnReplayDecision {
3895    Reserved,
3896    PendingLinked,
3897    Committed(ManagedWorktreeSpawnReceipt),
3898}
3899
3900impl SpawnIdempotencyValue {
3901    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3902        match self {
3903            Self::Standard { result, .. } => result
3904                .as_ref()
3905                .ok()
3906                .and_then(|receipt| receipt.context.as_ref()),
3907            Self::Managed { result, .. } => result
3908                .as_ref()
3909                .ok()
3910                .and_then(|receipt| receipt.spawn.context.as_ref()),
3911            Self::HarnessMcp => None,
3912        }
3913        .is_some_and(|receipt| &receipt.id == context_id)
3914    }
3915}
3916
3917#[derive(Clone, Copy)]
3918struct ControllerLease {
3919    connection_id: u64,
3920    expires_at: Instant,
3921}
3922
3923struct NodeEventHistory {
3924    last_sequence: u64,
3925    replay_floor_sequence: u64,
3926    events: VecDeque<NodeEventEnvelope>,
3927    record_providers: BTreeMap<SessionRecordId, AgentId>,
3928    removed_record_providers: BTreeMap<u64, AgentId>,
3929}
3930
3931impl NodeEventHistory {
3932    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3933        Self {
3934            last_sequence: 0,
3935            replay_floor_sequence: 1,
3936            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3937            record_providers,
3938            removed_record_providers: BTreeMap::new(),
3939        }
3940    }
3941}
3942
3943impl NodeShared {
3944    fn new_with_incarnation(
3945        handle: Gate4AgentHandle,
3946        access_token: String,
3947        node_id: NodeId,
3948        incarnation_id: NodeIncarnationId,
3949        workspaces: Vec<WorkspaceConfig>,
3950        enabled_providers: Vec<AgentId>,
3951        provider_runtime_statuses: ProviderRuntimeStatuses,
3952        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3953        provider_contracts: Vec<ProviderContractSupport>,
3954        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3955        spawn_profiles: SpawnProfileRegistry,
3956        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3957        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3958        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3959        state_path: Option<PathBuf>,
3960        records: Vec<ManagedSessionRecord>,
3961        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3962        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3963        materializations: Vec<MaterializationOwnershipRecord>,
3964        persistence_warning: Option<String>,
3965        input_settle_timeout_ms: u64,
3966    ) -> Self {
3967        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3968        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3969        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3970        let (harness_mcp_proxy_event_tx, _) =
3971            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3972        let record_providers = records
3973            .iter()
3974            .map(|record| (record.record_id.clone(), record.provider.clone()))
3975            .collect();
3976        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3977            .iter()
3978            .map(|workspace| {
3979                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3980            })
3981            .collect();
3982        for lease in &managed_worktrees {
3983            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3984        }
3985        let managed_worktree_profiles = workspaces.iter()
3986            .map(|workspace| (
3987                workspace.workspace_id.clone(),
3988                workspace.managed_worktree_profiles.clone(),
3989            ))
3990            .collect();
3991        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3992            managed_worktrees,
3993            managed_worktree_tombstones,
3994        ).expect("durable managed worktree registry was validated while loading");
3995        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3996        managed_worktrees.reattach_record_holders(
3997            &managed_worktree_record_holders(&records),
3998            unix_time_ms(),
3999        );
4000        Self {
4001            handle,
4002            access_token,
4003            node_id,
4004            incarnation_id,
4005            started_at_unix_ms: std::time::SystemTime::now()
4006                .duration_since(std::time::UNIX_EPOCH)
4007                .unwrap_or_default()
4008                .as_millis()
4009                .min(u64::MAX as u128) as u64,
4010            workspaces: RwLock::new(
4011                workspaces
4012                    .into_iter()
4013                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4014                    .collect(),
4015            ),
4016            worktree_service_modes,
4017            managed_worktree_profiles,
4018            managed_worktrees: Mutex::new(managed_worktrees),
4019            enabled_providers,
4020            provider_runtime_statuses,
4021            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4022            provider_runtime_monitor,
4023            provider_contracts,
4024            provider_adapter_contracts,
4025            spawn_profiles,
4026            environment_profiles: RwLock::new(BTreeMap::new()),
4027            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4028            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4029            #[cfg(feature = "dig2-station-probe")]
4030            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4031            bundle_catalog: RwLock::new(BundleCatalog::default()),
4032            delivery_store: Mutex::new(None),
4033            context_pack_store: Mutex::new(None),
4034            context_catalog: RwLock::new(ContextPackCatalog::default()),
4035            native_launch_profile_control,
4036            harness_mcp_registry: None,
4037            native_session_catalog,
4038            session_environment_materializer,
4039            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4040            m4a_live: Mutex::new(BTreeMap::new()),
4041            materializations: Mutex::new(
4042                materializations
4043                    .into_iter()
4044                    .map(|record| (record.id().clone(), record))
4045                    .collect(),
4046            ),
4047            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4048                entries: BTreeMap::new(),
4049            }),
4050            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4051            controller: Mutex::new(None),
4052            history: Mutex::new(NodeEventHistory::new(record_providers)),
4053            event_tx,
4054            terminal_event_tx,
4055            agent_stream_event_tx,
4056            harness_mcp_proxy_event_tx,
4057            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4058            pty_input_wake: tokio::sync::Notify::new(),
4059            pty_input_marks: Mutex::new(BTreeMap::new()),
4060            next_connection_id: AtomicU64::new(1),
4061            next_instance_id: AtomicU64::new(1),
4062            next_command_id: AtomicU64::new(1),
4063            shutdown: AtomicBool::new(false),
4064            shutdown_notify: Notify::new(),
4065            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4066            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4067            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4068            mutation_gate: AsyncMutex::new(()),
4069            session_bindings: Mutex::new(BTreeMap::new()),
4070            session_records: Mutex::new(SessionRecords {
4071                records: records
4072                    .into_iter()
4073                    .map(|record| (record.record_id.clone(), record))
4074                    .collect(),
4075            }),
4076            state_path,
4077            persistence_error: RwLock::new(
4078                persistence_warning
4079                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4080            ),
4081            state_transaction: Mutex::new(()),
4082            input_settle_timeout_ms,
4083            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4084            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4085            shell_efficiency_profile: OnceLock::new(),
4086            native_session_gauge: AtomicUsize::new(0),
4087            connection_loop_iterations: AtomicU64::new(0),
4088            connection_events_sent: AtomicU64::new(0),
4089            rejected_commands_total: AtomicU64::new(0),
4090            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4091            drive_loop_iterations_total: AtomicU64::new(0),
4092            drive_loop_iterations_idle: AtomicU64::new(0),
4093            session_record_retention: SessionRecordRetentionConfig::default(),
4094            retired_records_total: AtomicU64::new(0),
4095            #[cfg(feature = "fixture")]
4096            fixture_semantic_hook_policy: false,
4097            #[cfg(feature = "fixture")]
4098            fixture_spawn_managed_worktree_v2_failure_probe:
4099                SpawnManagedWorktreeV2FailureProbe::default(),
4100        }
4101    }
4102
4103    #[cfg(test)]
4104    fn new(
4105        handle: Gate4AgentHandle,
4106        access_token: String,
4107        node_id: NodeId,
4108        workspaces: Vec<WorkspaceConfig>,
4109        enabled_providers: Vec<AgentId>,
4110    ) -> Self {
4111        Self::new_with_incarnation(
4112            handle,
4113            access_token,
4114            node_id,
4115            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4116            workspaces,
4117            enabled_providers,
4118            ProviderRuntimeStatuses::default(),
4119            None,
4120            Vec::new(),
4121            Vec::new(),
4122            SpawnProfileRegistry::default(),
4123            None,
4124            None,
4125            None,
4126            None,
4127            Vec::new(),
4128            Vec::new(),
4129            Vec::new(),
4130            Vec::new(),
4131            None,
4132            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4133        )
4134    }
4135
4136    fn persistence_error(&self) -> Option<String> {
4137        self.persistence_error
4138            .read()
4139            .unwrap_or_else(|poisoned| poisoned.into_inner())
4140            .clone()
4141    }
4142
4143    async fn admit_provider_runtime(
4144        &self,
4145        provider: &AgentId,
4146        requirement: ProviderRuntimeRequirement,
4147    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4148        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4149            let pipe_transport = monitor.supports_pipe_transport(provider);
4150            let acp_transport = monitor.supports_acp_transport(provider);
4151            let provider = provider.clone();
4152            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4153            let (status, admission) = timeout(
4154                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4155                refresh,
4156            )
4157            .await
4158            .map_err(|_| failure(
4159                NodeFailureCode::BackendBusy,
4160                "provider runtime probe exceeded its bounded deadline",
4161            ))?
4162            .map_err(|_| failure(
4163                NodeFailureCode::BackendOperationFailed,
4164                "provider runtime probe task failed",
4165            ))?;
4166            if let Some(status) = status {
4167                self
4168                    .provider_runtime_status_updates
4169                    .write()
4170                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4171                    .insert(status.provider().clone(), status);
4172            }
4173            admission.and_then(|policy| {
4174                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4175            })
4176        } else {
4177            // No live monitor means no catalog to consult -- there is
4178            // nothing here to confirm this provider declares a Pipe or ACP
4179            // transport, so Inline/Acp are rejected the same conservative
4180            // way an undeclared provider always was, rather than admitted
4181            // on trust.
4182            crate::provider_runtime::admit_status(
4183                &self.provider_runtime_statuses,
4184                provider,
4185                false,
4186                false,
4187                requirement,
4188            )
4189        };
4190        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4191        admission.map_err(|error| match error {
4192            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4193                NodeFailureCode::BackendOperationFailed,
4194                "provider launcher is unavailable",
4195            ),
4196            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4197                NodeFailureCode::UnsupportedCapability,
4198                "provider semantic capability is not verified",
4199            ),
4200            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4201                NodeFailureCode::BackendBusy,
4202                "provider runtime probe is already in progress",
4203            ),
4204        })
4205    }
4206
4207    /// Whether the catalog declares a Pipe transport for `provider` --
4208    /// the fact `require_policy` needs to admit or reject
4209    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4210    /// catalog to consult, so this stays conservative and rejects, matching
4211    /// `admit_provider_runtime`'s own no-monitor branch.
4212    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4213        match self.provider_runtime_monitor.as_deref() {
4214            Some(monitor) => monitor.supports_pipe_transport(provider),
4215            None => false,
4216        }
4217    }
4218
4219    /// Whether the catalog declares an ACP transport for `provider` -- the
4220    /// fact `require_policy` needs to admit or reject
4221    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4222    /// `provider_supports_pipe_transport` exactly, including the
4223    /// conservative no-monitor default.
4224    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4225        match self.provider_runtime_monitor.as_deref() {
4226            Some(monitor) => monitor.supports_acp_transport(provider),
4227            None => false,
4228        }
4229    }
4230
4231    /// Grants hook ingestion to a provider whose catalog entry declares a
4232    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4233    ///
4234    /// `admit_status`'s `RawPassthrough` arm hands back
4235    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4236    /// correct for everything it was written to answer, and wrong for hooks
4237    /// specifically. A hook event is not inferred from terminal behaviour,
4238    /// so it has nothing to do with whether a launcher probe verified this
4239    /// CLI version's PTY contract; the authority is the catalog declaring
4240    /// the adapter plus the ingress authenticating the route. Without this
4241    /// the node installs a provider's hooks, the CLI faithfully calls them,
4242    /// and every event is refused -- which is exactly the state grok was in.
4243    ///
4244    /// Applied to both admission paths so the monitor's own derivation and
4245    /// this coarse fallback cannot disagree about the same provider.
4246    fn admit_declared_hook_semantics(
4247        &self,
4248        provider: &AgentId,
4249        policy: ProviderRuntimePolicy,
4250    ) -> ProviderRuntimePolicy {
4251        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4252            return policy;
4253        }
4254        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4255            contract.provider == *provider && contract.family == AdapterFamily::Hook
4256        });
4257        if !declares_hook {
4258            return policy;
4259        }
4260        ProviderRuntimePolicy::new(
4261            policy.raw_pty_lifecycle,
4262            policy.semantic_readiness,
4263            policy.structured_prompt,
4264            policy.provider_session_identity,
4265            policy.semantic_resume,
4266            true,
4267        )
4268        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4269    }
4270
4271    /// Station network allowlist → provider launch overlay argv.
4272    ///
4273    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4274    ///   `provider_native` under Moderate (PTY or ACP).
4275    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4276    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4277    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4278    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4279    ///   no overlay.
4280    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4281    ///   empty overlay when no `provider_native`; invented native keys already
4282    ///   refuse at load / resolve.
4283    ///
4284    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4285    fn resolve_network_allowlist_overlay_args(
4286        &self,
4287        provider: &AgentId,
4288        mode: SessionMode,
4289        approval_level: ApprovalLevel,
4290        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4291        provider_home: Option<&Path>,
4292    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4293        let Some(allowlist_id) = network_allowlist else {
4294            return Ok(Vec::new());
4295        };
4296        let catalog = self
4297            .network_allowlist_catalog
4298            .read()
4299            .unwrap_or_else(|poisoned| poisoned.into_inner());
4300        let Some(entry) = catalog.get(allowlist_id) else {
4301            return Err(failure(
4302                NodeFailureCode::UnknownNetworkAllowlist,
4303                "spawn network allowlist is unavailable on this node",
4304            ));
4305        };
4306
4307        // Claude: settings-shaped overlay from permits (not provider_native).
4308        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4309            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4310                |message| {
4311                    failure(
4312                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4313                        &message,
4314                    )
4315                },
4316            )?;
4317            if !matches!(mode, SessionMode::Pty) {
4318                return Err(failure(
4319                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4320                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4321                ));
4322            }
4323            let Some(home) = provider_home else {
4324                return Err(failure(
4325                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4326                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4327                ));
4328            };
4329            let domains =
4330                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4331            if domains.is_empty() {
4332                return Err(failure(
4333                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4334                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4335                ));
4336            }
4337            let settings_path = home.join(
4338                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4339            );
4340            let json =
4341                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4342            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4343                |_| {
4344                    failure(
4345                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4346                        "Claude station network settings sidecar could not be written under ProviderHome",
4347                    )
4348                },
4349            )?;
4350            let overlay =
4351                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4352            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4353        }
4354
4355        let Some(native) = entry.provider_native.as_ref() else {
4356            return Ok(Vec::new());
4357        };
4358        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4359            provider.as_str(),
4360            approval_level,
4361            native,
4362        )
4363        .map_err(|message| {
4364            failure(
4365                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4366                &message,
4367            )
4368        })?;
4369        if overlay.is_empty() {
4370            return Ok(Vec::new());
4371        }
4372        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4373            return Err(failure(
4374                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4375                "Codex network_access -c overlay requires PTY or ACP transport",
4376            ));
4377        }
4378        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4379    }
4380
4381    /// Backward-compatible alias used by Codex-focused unit tests.
4382    fn resolve_codex_network_access_overlay_args(
4383        &self,
4384        provider: &AgentId,
4385        mode: SessionMode,
4386        approval_level: ApprovalLevel,
4387        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4388    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4389        self.resolve_network_allowlist_overlay_args(
4390            provider,
4391            mode,
4392            approval_level,
4393            network_allowlist,
4394            None,
4395        )
4396    }
4397
4398    fn resolve_environment_profile(
4399        &self,
4400        resolved: &ResolvedSpawnSpec,
4401    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4402        // Station network allowlist catalog enforce (plan
4403        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4404        // catalog — unknown ids refuse rather than silent ambient. Runs even
4405        // when no environment_profile_id is present so a bare allowlist still
4406        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4407        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4408            let catalog = self
4409                .network_allowlist_catalog
4410                .read()
4411                .unwrap_or_else(|poisoned| poisoned.into_inner());
4412            let Some(entry) = catalog.get(allowlist_id) else {
4413                return Err(failure(
4414                    NodeFailureCode::UnknownNetworkAllowlist,
4415                    "spawn network allowlist is unavailable on this node",
4416                ));
4417            };
4418            // Track B: refuse provider-native mappings this provider cannot
4419            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4420            if let Some(native) = entry.provider_native.as_ref() {
4421                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4422                    resolved.provider.as_str(),
4423                    resolved.approval_level,
4424                    native,
4425                ) {
4426                    return Err(failure(
4427                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4428                        &message,
4429                    ));
4430                }
4431            }
4432        }
4433        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4434        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4435        // cheap named-pipe path reachability must succeed (Windows
4436        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4437        // NodeFailureCode. Runs even without environment_profile_id (same
4438        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4439        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4440        // acquired later in spawn_session_with_deadline (after instance id).
4441        #[cfg(feature = "dig2-station-probe")]
4442        if resolved.browser_profile_id.is_some() {
4443            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4444            match probe_station_reachable() {
4445                Ok(()) => {}
4446                Err(StationProbeError::UnsupportedPlatform)
4447                | Err(StationProbeError::InvalidPipeSuffix) => {
4448                    return Err(failure(
4449                        NodeFailureCode::BrowserStationProbeUnavailable,
4450                        "dig2browser station probe unavailable on this platform",
4451                    ));
4452                }
4453                Err(StationProbeError::Unreachable) => {
4454                    return Err(failure(
4455                        NodeFailureCode::BrowserStationUnreachable,
4456                        "dig2browser station named-pipe is unreachable",
4457                    ));
4458                }
4459            }
4460        }
4461        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4462            return Ok(None);
4463        };
4464        let profiles = self
4465            .environment_profiles
4466            .read()
4467            .unwrap_or_else(|poisoned| poisoned.into_inner());
4468        let binding = profiles.get(profile_id).ok_or_else(|| {
4469            failure(
4470                NodeFailureCode::UnknownEnvironmentProfile,
4471                "spawn environment profile is unavailable on this node",
4472            )
4473        })?;
4474        if binding.provider != resolved.provider
4475            || binding.native_profile_id(resolved.mode).is_none()
4476        {
4477            return Err(failure(
4478                NodeFailureCode::EnvironmentProfileBindingMismatch,
4479                "spawn environment profile does not match the provider and mode",
4480            ));
4481        }
4482        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4483        // after catalog + optional dig2 probe above. Empty/whitespace ids
4484        // already refuse at Spawn*Id construction.
4485        Ok(Some(ResolvedEnvironmentProfileReceipt {
4486            profile_id: binding.id.clone(),
4487            profile_revision: binding.revision.clone(),
4488            network_allowlist: resolved.network_allowlist.clone(),
4489            browser_profile_id: resolved.browser_profile_id.clone(),
4490        }))
4491    }
4492
4493    fn resolve_bundle(
4494        &self,
4495        resolved: &ResolvedSpawnSpec,
4496        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4497    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4498        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4499            return Ok(None);
4500        };
4501        let catalog = self.bundle_catalog
4502            .read()
4503            .unwrap_or_else(|poisoned| poisoned.into_inner());
4504        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4505                failure(
4506                    NodeFailureCode::UnknownBundle,
4507                    "spawn bundle is unavailable on this node",
4508                )
4509            })?;
4510        self.bundle_layout(
4511            &resolved.provider,
4512            resolved.mode,
4513            environment_profile,
4514            bundle,
4515        )?;
4516        Ok(Some(bundle.receipt()))
4517    }
4518
4519    fn begin_delivery_stage(
4520        &self,
4521        manifest: DeliveryBundleManifestV2,
4522    ) -> Result<NodeResponse, NodeFailure> {
4523        let mut store = self
4524            .delivery_store
4525            .lock()
4526            .unwrap_or_else(|poisoned| poisoned.into_inner());
4527        let store = store.as_mut().ok_or_else(|| {
4528            failure(
4529                NodeFailureCode::DeliveryStageStorageFailed,
4530                "node delivery store is unavailable",
4531            )
4532        })?;
4533        let begun = store.begin(manifest).map_err(delivery_failure)?;
4534        Ok(NodeResponse::DeliveryStageBegun {
4535            stage_id: begun.stage_id,
4536            manifest_digest: begun.manifest_digest,
4537            missing_blobs: begun.missing_blobs,
4538        })
4539    }
4540
4541    fn put_delivery_blob_chunk(
4542        &self,
4543        stage_id: DeliveryStageId,
4544        blob_digest: DeliveryBlobDigestV1,
4545        offset: u64,
4546        chunk_hex: DeliveryBlobChunkHexV1,
4547    ) -> Result<NodeResponse, NodeFailure> {
4548        let mut store = self
4549            .delivery_store
4550            .lock()
4551            .unwrap_or_else(|poisoned| poisoned.into_inner());
4552        let store = store.as_mut().ok_or_else(|| {
4553            failure(
4554                NodeFailureCode::DeliveryStageStorageFailed,
4555                "node delivery store is unavailable",
4556            )
4557        })?;
4558        let accepted = store
4559            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4560            .map_err(delivery_failure)?;
4561        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4562            stage_id: accepted.stage_id,
4563            blob_digest: accepted.blob_digest,
4564            next_offset: accepted.next_offset,
4565        })
4566    }
4567
4568    fn commit_delivery_stage(
4569        &self,
4570        stage_id: DeliveryStageId,
4571    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4572        let mut store = self
4573            .delivery_store
4574            .lock()
4575            .unwrap_or_else(|poisoned| poisoned.into_inner());
4576        let store = store.as_mut().ok_or_else(|| {
4577            failure(
4578                NodeFailureCode::DeliveryStageStorageFailed,
4579                "node delivery store is unavailable",
4580            )
4581        })?;
4582        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4583        let mut catalog = self
4584            .bundle_catalog
4585            .write()
4586            .unwrap_or_else(|poisoned| poisoned.into_inner());
4587        let mut next_catalog = catalog.clone();
4588        next_catalog
4589            .insert_idempotent(prepared.bundle().clone())
4590            .map_err(|_| {
4591                failure(
4592                    NodeFailureCode::DeliveryStageConflict,
4593                    "delivery bundle identity conflicts with installed content",
4594                )
4595            })?;
4596        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4597        *catalog = next_catalog;
4598        Ok(receipt)
4599    }
4600
4601    fn abort_delivery_stage(
4602        &self,
4603        stage_id: &DeliveryStageId,
4604    ) -> Result<(), NodeFailure> {
4605        let mut store = self
4606            .delivery_store
4607            .lock()
4608            .unwrap_or_else(|poisoned| poisoned.into_inner());
4609        let store = store.as_mut().ok_or_else(|| {
4610            failure(
4611                NodeFailureCode::DeliveryStageStorageFailed,
4612                "node delivery store is unavailable",
4613            )
4614        })?;
4615        store.abort(stage_id).map_err(delivery_failure)
4616    }
4617
4618    fn resolve_context(
4619        &self,
4620        resolved: &ResolvedSpawnSpec,
4621    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4622        let Some(context_id) = resolved.context_id.as_ref() else {
4623            return Ok(None);
4624        };
4625        self.context_catalog
4626            .read()
4627            .unwrap_or_else(|poisoned| poisoned.into_inner())
4628            .get(context_id)
4629            .map(|context| context.receipt().clone())
4630            .ok_or_else(|| {
4631                failure(
4632                    NodeFailureCode::UnknownContextPack,
4633                    "spawn context pack is unavailable on this node",
4634                )
4635            })
4636            .map(Some)
4637    }
4638
4639    fn bundle_layout(
4640        &self,
4641        provider: &AgentId,
4642        mode: SessionMode,
4643        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4644        bundle: &NodeBundle,
4645    ) -> Result<BundleProviderLayout, NodeFailure> {
4646        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4647            failure(
4648                NodeFailureCode::BundleBindingMismatch,
4649                "spawn bundle does not match the provider and mode",
4650            )
4651        })?;
4652        if layout != BundleProviderLayout::Codex {
4653            return Ok(layout);
4654        }
4655        let profile = environment_profile.and_then(|receipt| {
4656            let profiles = self
4657                .environment_profiles
4658                .read()
4659                .unwrap_or_else(|poisoned| poisoned.into_inner());
4660            let binding = profiles.get(&receipt.profile_id)?;
4661            if binding.revision != receipt.profile_revision
4662                || &binding.provider != provider
4663                || binding.native_profile_id(mode).is_none()
4664            {
4665                return None;
4666            }
4667            self.environment_materialization_profiles
4668                .read()
4669                .unwrap_or_else(|poisoned| poisoned.into_inner())
4670                .get(&receipt.profile_id)
4671                .cloned()
4672        });
4673        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4674            return Err(failure(
4675                NodeFailureCode::BundleBindingMismatch,
4676                "Codex bundle requires an exact isolated CODEX_HOME profile",
4677            ));
4678        }
4679        Ok(layout)
4680    }
4681
4682    fn select_environment_profile(
4683        &self,
4684        instance_id: AgentInstanceId,
4685        provider: &AgentId,
4686        mode: SessionMode,
4687        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4688    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4689        let Some(environment_profile) = environment_profile else {
4690            return Ok(None);
4691        };
4692        let native_profile_id = {
4693            let profiles = self
4694                .environment_profiles
4695                .read()
4696                .unwrap_or_else(|poisoned| poisoned.into_inner());
4697            let binding = profiles
4698                .get(&environment_profile.profile_id)
4699                .ok_or_else(|| {
4700                    failure(
4701                        NodeFailureCode::UnknownEnvironmentProfile,
4702                        "session environment profile is unavailable on this node",
4703                    )
4704                })?;
4705            if &binding.revision != &environment_profile.profile_revision
4706                || &binding.provider != provider
4707            {
4708                return Err(failure(
4709                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4710                    "session environment profile revision or provider changed",
4711                ));
4712            }
4713            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4714                failure(
4715                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4716                    "session environment profile does not support this mode",
4717                )
4718            })?
4719        };
4720        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4721            failure(
4722                NodeFailureCode::BackendOperationFailed,
4723                "native environment profile controller is unavailable",
4724            )
4725        })?;
4726        control
4727            .select_native_launch_profile(instance_id, native_profile_id)
4728            .map_err(|_| {
4729                failure(
4730                    NodeFailureCode::BackendOperationFailed,
4731                    "native environment profile selection failed",
4732                )
4733            })?;
4734        Ok(Some(NativeEnvironmentSelectionGuard {
4735            control: Some(control),
4736            instance_id,
4737        }))
4738    }
4739
4740    fn install_prepared_launch_overlay(
4741        &self,
4742        instance_id: AgentInstanceId,
4743        overlay: PreparedNativeLaunchOverlay,
4744    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4745        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4746            failure(
4747                NodeFailureCode::BackendOperationFailed,
4748                "native launch profile controller is unavailable",
4749            )
4750        })?;
4751        match overlay {
4752            PreparedNativeLaunchOverlay::Environment(overlay) => {
4753                control
4754                    .install_native_launch_environment_overlay(instance_id, overlay)
4755                    .map_err(|_| failure(
4756                        NodeFailureCode::BackendOperationFailed,
4757                        "session environment overlay installation failed",
4758                    ))?;
4759                Ok(None)
4760            }
4761            PreparedNativeLaunchOverlay::Instance(overlay) => {
4762                control
4763                    .install_native_instance_launch_overlay(instance_id, overlay)
4764                    .map_err(|_| failure(
4765                        NodeFailureCode::BundleBindingMismatch,
4766                        "session bundle launch overlay installation failed",
4767                    ))?;
4768                Ok(Some(NativeInstanceOverlayGuard {
4769                    control: Some(control),
4770                    instance_id,
4771                }))
4772            }
4773        }
4774    }
4775
4776    fn environment_materialization_profile(
4777        &self,
4778        environment_profile: &ResolvedEnvironmentProfileReceipt,
4779    ) -> Option<NodeSessionMaterializationProfile> {
4780        self.environment_materialization_profiles
4781            .read()
4782            .unwrap_or_else(|poisoned| poisoned.into_inner())
4783            .get(&environment_profile.profile_id)
4784            .cloned()
4785    }
4786
4787    fn materialization_profile(
4788        &self,
4789        provider: &AgentId,
4790        mode: SessionMode,
4791        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4792        bundle: Option<&ResolvedBundleReceipt>,
4793        context: Option<&ResolvedContextPackReceipt>,
4794    ) -> Result<Option<(
4795        NodeSessionMaterializationProfile,
4796        bool,
4797        Option<BundleProviderLayout>,
4798    )>, NodeFailure> {
4799        let environment = environment_profile
4800            .and_then(|receipt| self.environment_materialization_profile(receipt));
4801        let (bundle, bundle_layout) = match bundle {
4802            Some(receipt) => {
4803                let catalog = self.bundle_catalog.read()
4804                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4805                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4806                    NodeFailureCode::UnknownBundle,
4807                    "session bundle is unavailable on this node",
4808                ))?;
4809                if installed.receipt() != *receipt {
4810                    return Err(failure(
4811                        NodeFailureCode::BundleBindingMismatch,
4812                        "session bundle revision or digest changed",
4813                    ));
4814                }
4815                let layout = self.bundle_layout(
4816                    provider,
4817                    mode,
4818                    environment_profile,
4819                    installed,
4820                )?;
4821                (Some(installed.clone()), Some(layout))
4822            }
4823            None => (None, None),
4824        };
4825        let has_environment_overlay = environment.is_some() || context.is_some();
4826        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4827            (None, None, None) => None,
4828            (Some(profile), None, None) => Some(Ok(profile)),
4829            (None, Some(bundle), Some(layout)) => {
4830                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4831            }
4832            (Some(profile), Some(bundle), Some(layout)) => {
4833                Some(profile.with_bundle(bundle, layout))
4834            }
4835            _ => {
4836                return Err(failure(
4837                    NodeFailureCode::BundleBindingMismatch,
4838                    "session bundle provider layout is unavailable",
4839                ));
4840            }
4841        };
4842        let profile = profile
4843            .transpose()
4844            .map_err(|_| failure(
4845                NodeFailureCode::BundleMaterializationFailed,
4846                "session bundle materialization profile is invalid",
4847            ))?;
4848        let profile = match (profile, context) {
4849            (Some(profile), 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                profile.with_context(pack)
4864            }
4865            (None, Some(receipt)) => {
4866                let catalog = self.context_catalog
4867                    .read()
4868                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4869                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4870                    NodeFailureCode::UnknownContextPack,
4871                    "session context pack is unavailable on this node",
4872                ))?;
4873                if pack.receipt() != receipt {
4874                    return Err(failure(
4875                        NodeFailureCode::ContextPackMaterializationFailed,
4876                        "session context pack identity changed",
4877                    ));
4878                }
4879                NodeSessionMaterializationProfile::from_context(pack)
4880            }
4881            (Some(profile), None) => Ok(profile),
4882            (None, None) => return Ok(None),
4883        }
4884        .map_err(|_| failure(
4885            NodeFailureCode::ContextPackMaterializationFailed,
4886            "session context pack materialization profile is invalid",
4887        ))?;
4888        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4889    }
4890
4891    fn prepare_session_materialization<'a>(
4892        &'a self,
4893        address: &SessionAddress,
4894        provider: &AgentId,
4895        mode: SessionMode,
4896        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4897        bundle: Option<&ResolvedBundleReceipt>,
4898        context: Option<&ResolvedContextPackReceipt>,
4899        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4900    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4901        let Some((profile, has_environment_overlay, bundle_layout)) =
4902            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4903        else {
4904            return Ok(None);
4905        };
4906        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4907            failure(
4908                NodeFailureCode::BackendOperationFailed,
4909                "session environment materializer is unavailable",
4910            )
4911        })?;
4912        let materialization_failure_code = if context.is_some() {
4913            NodeFailureCode::ContextPackMaterializationFailed
4914        } else if bundle.is_some() {
4915            NodeFailureCode::BundleMaterializationFailed
4916        } else {
4917            NodeFailureCode::BackendOperationFailed
4918        };
4919        let id = MaterializationId::new(format!(
4920            "mat-{}-{}-{}",
4921            self.incarnation_id,
4922            address.session.instance_id.0,
4923            address.session.generation.0,
4924        ))
4925        .map_err(|_| {
4926            failure(
4927                NodeFailureCode::BackendOperationFailed,
4928                "session environment materialization identity failed",
4929            )
4930        })?;
4931        let owner = MaterializationOwner::Session {
4932            incarnation_id: self.incarnation_id,
4933            instance_id: address.session.instance_id,
4934            generation: address.session.generation,
4935        };
4936        let mut ownership = materializer
4937            .begin(
4938                id.clone(),
4939                environment_profile.cloned(),
4940                bundle.cloned(),
4941                context.cloned(),
4942                owner,
4943                managed_lease_id,
4944                &profile,
4945                unix_time_ms(),
4946            )
4947            .map_err(|_| {
4948                failure(
4949                    materialization_failure_code,
4950                    "session materialization preparation failed",
4951                )
4952            })?;
4953        {
4954            let _transaction = self
4955                .state_transaction
4956                .lock()
4957                .unwrap_or_else(|poisoned| poisoned.into_inner());
4958            let duplicate = self
4959                .materializations
4960                .lock()
4961                .unwrap_or_else(|poisoned| poisoned.into_inner())
4962                .insert(id.clone(), ownership.clone());
4963            if duplicate.is_some() {
4964                return Err(failure(
4965                    NodeFailureCode::BackendOperationFailed,
4966                    "session environment materialization identity conflict",
4967                ));
4968            }
4969            if let Err(error) = self.persist_state_locked() {
4970                self.materializations
4971                    .lock()
4972                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4973                    .remove(&id);
4974                return Err(persistence_failure(error));
4975            }
4976        }
4977        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4978            Ok(environment) => environment,
4979            Err(_) => {
4980                let transaction = self
4981                    .state_transaction
4982                    .lock()
4983                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4984                self.materializations
4985                    .lock()
4986                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4987                    .insert(id.clone(), ownership.clone());
4988                if self.persist_state_locked().is_err() {
4989                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4990                }
4991                drop(transaction);
4992                if ownership.state() == MaterializationState::CleanupRequired {
4993                    let _ = self.cleanup_materialization(&id);
4994                }
4995                return Err(failure(
4996                    materialization_failure_code,
4997                    "session materialization failed",
4998                ));
4999            }
5000        };
5001        {
5002            let _transaction = self
5003                .state_transaction
5004                .lock()
5005                .unwrap_or_else(|poisoned| poisoned.into_inner());
5006            self.materializations
5007                .lock()
5008                .unwrap_or_else(|poisoned| poisoned.into_inner())
5009                .insert(id.clone(), ownership.clone());
5010            if let Err(error) = self.persist_state_locked() {
5011                drop(_transaction);
5012                let _ = self.cleanup_materialization(&id);
5013                return Err(persistence_failure(error));
5014            }
5015        }
5016        let guard = SessionMaterializationGuard {
5017            shared: self,
5018            id: Some(id),
5019            provider_home: Some(ownership.provider_home().to_path_buf()),
5020        };
5021        let bundle_arguments = match bundle {
5022            Some(receipt) => {
5023                let catalog = self.bundle_catalog.read()
5024                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5025                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5026                    NodeFailureCode::UnknownBundle,
5027                    "session bundle is unavailable on this node",
5028                ))?;
5029                if installed.receipt() != *receipt {
5030                    return Err(failure(
5031                        NodeFailureCode::BundleBindingMismatch,
5032                        "session bundle revision or digest changed",
5033                    ));
5034                }
5035                let layout = bundle_layout.ok_or_else(|| failure(
5036                    NodeFailureCode::BundleBindingMismatch,
5037                    "materialized bundle provider layout is unavailable",
5038                ))?;
5039                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5040                    failure(
5041                        NodeFailureCode::BundleMaterializationFailed,
5042                        "session bundle byte revalidation failed",
5043                    )
5044                })?;
5045                Some(bundle_launch_arguments(
5046                    layout,
5047                    ownership.bundle_root(),
5048                    ownership.provider_home(),
5049                ).map_err(|_| failure(
5050                    NodeFailureCode::BundleBindingMismatch,
5051                    "session bundle launch binding is invalid",
5052                ))?)
5053            }
5054            None => None,
5055        };
5056        if let Some(receipt) = context {
5057            materializer
5058                .revalidate_context(&ownership, receipt)
5059                .map_err(|_| failure(
5060                    NodeFailureCode::ContextPackMaterializationFailed,
5061                    "session context pack byte revalidation failed",
5062                ))?;
5063        }
5064        let transport = match mode {
5065            SessionMode::Pty => TransportKind::Pty,
5066            SessionMode::Inline => TransportKind::Pipe,
5067            SessionMode::Acp => TransportKind::Acp,
5068        };
5069        let overlay = if let Some(extra_args) = bundle_arguments {
5070            Some(PreparedNativeLaunchOverlay::Instance(
5071                NativeInstanceLaunchOverlay::new(
5072                    provider.clone(),
5073                    transport,
5074                    if has_environment_overlay {
5075                        environment
5076                    } else {
5077                        Vec::new()
5078                    },
5079                    extra_args,
5080                )
5081                .map_err(|_| {
5082                    failure(
5083                        NodeFailureCode::BundleBindingMismatch,
5084                        "session bundle launch overlay is invalid",
5085                    )
5086                })?,
5087            ))
5088        } else if has_environment_overlay {
5089            let context_only = environment_profile.is_none()
5090                && bundle.is_none()
5091                && context.is_some();
5092            let overlay = if context_only {
5093                NativeLaunchEnvironmentOverlay::new_context_root(
5094                    provider.clone(),
5095                    transport,
5096                    environment,
5097                )
5098                .map_err(|_| {
5099                    failure(
5100                        NodeFailureCode::ContextPackMaterializationFailed,
5101                        "context-only launch overlay is invalid",
5102                    )
5103                })?
5104            } else {
5105                NativeLaunchEnvironmentOverlay::new(
5106                    provider.clone(),
5107                    transport,
5108                    environment,
5109                )
5110                .map_err(|_| {
5111                    failure(
5112                        NodeFailureCode::BackendOperationFailed,
5113                        "session environment overlay is invalid",
5114                    )
5115                })?
5116            };
5117            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5118        } else {
5119            None
5120        };
5121        Ok(Some((overlay, guard)))
5122    }
5123
5124    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5125        let Some(mut ownership) = self
5126            .materializations
5127            .lock()
5128            .unwrap_or_else(|poisoned| poisoned.into_inner())
5129            .get(id)
5130            .cloned()
5131        else {
5132            return Ok(());
5133        };
5134        if ownership.state() != MaterializationState::CleanupRequired {
5135            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5136                failure(
5137                    NodeFailureCode::BackendOperationFailed,
5138                    "session environment cleanup state is invalid",
5139                )
5140            })?;
5141        }
5142        {
5143            let _transaction = self
5144                .state_transaction
5145                .lock()
5146                .unwrap_or_else(|poisoned| poisoned.into_inner());
5147            let previous = self
5148                .materializations
5149                .lock()
5150                .unwrap_or_else(|poisoned| poisoned.into_inner())
5151                .insert(id.clone(), ownership.clone());
5152            if let Err(error) = self.persist_state_locked() {
5153                let mut materializations = self
5154                    .materializations
5155                    .lock()
5156                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5157                if let Some(previous) = previous {
5158                    materializations.insert(id.clone(), previous);
5159                }
5160                return Err(persistence_failure(error));
5161            }
5162        }
5163        let cleanup = self
5164            .session_environment_materializer
5165            .as_ref()
5166            .ok_or(())
5167            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5168        if cleanup.is_err() {
5169            let _ = ownership.mark_recovery_required(unix_time_ms());
5170            self.materializations
5171                .lock()
5172                .unwrap_or_else(|poisoned| poisoned.into_inner())
5173                .insert(id.clone(), ownership);
5174            if self.persist_state().is_err() {
5175                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5176            }
5177            return Err(failure(
5178                NodeFailureCode::BackendOperationFailed,
5179                "session environment cleanup requires recovery",
5180            ));
5181        }
5182        let _transaction = self
5183            .state_transaction
5184            .lock()
5185            .unwrap_or_else(|poisoned| poisoned.into_inner());
5186        self.materializations
5187            .lock()
5188            .unwrap_or_else(|poisoned| poisoned.into_inner())
5189            .remove(id);
5190        if let Err(error) = self.persist_state_locked() {
5191            self.materializations
5192                .lock()
5193                .unwrap_or_else(|poisoned| poisoned.into_inner())
5194                .insert(id.clone(), ownership);
5195            return Err(persistence_failure(error));
5196        }
5197        Ok(())
5198    }
5199
5200    fn resolve_record_materialization(
5201        &self,
5202        record_id: &SessionRecordId,
5203        provider: &AgentId,
5204        mode: SessionMode,
5205        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5206        bundle: Option<&ResolvedBundleReceipt>,
5207        context: Option<&ResolvedContextPackReceipt>,
5208    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5209        let ownership = {
5210            let materializations = self
5211                .materializations
5212                .lock()
5213                .unwrap_or_else(|poisoned| poisoned.into_inner());
5214            let mut matches = materializations.values().filter(|record| {
5215                record.owner()
5216                    == &MaterializationOwner::Record {
5217                        record_id: record_id.clone(),
5218                    }
5219            });
5220            let ownership = matches.next().cloned();
5221            if matches.next().is_some() {
5222                return Err(failure(
5223                    NodeFailureCode::BackendOperationFailed,
5224                    "managed session has conflicting environment ownership",
5225                ));
5226            }
5227            ownership
5228        };
5229        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5230            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5231                failure(
5232                    NodeFailureCode::ContextPackMaterializationFailed,
5233                    "session context materializer is unavailable",
5234                )
5235            })?;
5236            let pack = materializer
5237                .revalidate_context(ownership, receipt)
5238                .map_err(|_| {
5239                    self.mark_materialization_recovery_required(ownership.id());
5240                    failure(
5241                        NodeFailureCode::ContextPackMaterializationFailed,
5242                        "managed session context pack revalidation failed",
5243                    )
5244                })?;
5245            self.context_catalog
5246                .write()
5247                .unwrap_or_else(|poisoned| poisoned.into_inner())
5248                .insert(pack)
5249                .map_err(|_| failure(
5250                    NodeFailureCode::ContextPackMaterializationFailed,
5251                    "managed session context pack catalog restore failed",
5252                ))?;
5253        }
5254        let materialization = self.materialization_profile(
5255            provider,
5256            mode,
5257            environment_profile,
5258            bundle,
5259            context,
5260        )?;
5261        match (ownership, materialization) {
5262            (None, None) => Ok(None),
5263            (None, Some(_)) | (Some(_), None) => Err(failure(
5264                NodeFailureCode::BackendOperationFailed,
5265                "managed session materialization is unavailable",
5266            )),
5267            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5268                if ownership.environment_profile() != environment_profile
5269                    || ownership.bundle() != bundle
5270                    || ownership.context() != context
5271                    || ownership.state() != MaterializationState::Ready
5272                {
5273                    self.mark_materialization_recovery_required(ownership.id());
5274                    return Err(failure(
5275                        NodeFailureCode::BackendOperationFailed,
5276                        "managed session environment ownership requires recovery",
5277                    ));
5278                }
5279                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5280                    failure(
5281                        NodeFailureCode::BackendOperationFailed,
5282                        "session environment materializer is unavailable",
5283                    )
5284                })?;
5285                if materializer.revalidate(&ownership).is_err() {
5286                    self.mark_materialization_recovery_required(ownership.id());
5287                    return Err(failure(
5288                        NodeFailureCode::BackendOperationFailed,
5289                        "managed session environment revalidation failed",
5290                    ));
5291                }
5292                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5293                    let catalog = self.bundle_catalog.read()
5294                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5295                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5296                        return Err(failure(
5297                            NodeFailureCode::UnknownBundle,
5298                            "managed session bundle is unavailable",
5299                        ));
5300                    };
5301                    let layout = bundle_layout.ok_or_else(|| failure(
5302                        NodeFailureCode::BundleBindingMismatch,
5303                        "record bundle provider layout is unavailable",
5304                    ))?;
5305                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5306                        drop(catalog);
5307                        self.mark_materialization_recovery_required(ownership.id());
5308                        return Err(failure(
5309                            NodeFailureCode::BundleMaterializationFailed,
5310                            "managed session bundle revalidation failed",
5311                        ));
5312                    }
5313                    Some(bundle_launch_arguments(
5314                        layout,
5315                        ownership.bundle_root(),
5316                        ownership.provider_home(),
5317                    ).map_err(|_| failure(
5318                        NodeFailureCode::BundleBindingMismatch,
5319                        "managed session bundle launch binding is invalid",
5320                    ))?)
5321                } else {
5322                    None
5323                };
5324                if !has_environment_materialization {
5325                    let overlay = bundle_arguments.map(|extra_args| {
5326                        NativeInstanceLaunchOverlay::new(
5327                            provider.clone(),
5328                            TransportKind::Pty,
5329                            Vec::new(),
5330                            extra_args,
5331                        )
5332                        .map(PreparedNativeLaunchOverlay::Instance)
5333                        .map_err(|_| failure(
5334                            NodeFailureCode::BundleBindingMismatch,
5335                            "managed session bundle launch overlay is invalid",
5336                        ))
5337                    }).transpose()?;
5338                    return Ok(Some((ownership.id().clone(), overlay)));
5339                }
5340                let environment = match materializer.resolve_environment(&ownership, &profile) {
5341                    Ok(environment) => environment,
5342                    Err(
5343                        SessionEnvironmentMaterializeError::SecretUnavailable
5344                        | SessionEnvironmentMaterializeError::SecretDenied
5345                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5346                    ) => {
5347                        return Err(failure(
5348                            NodeFailureCode::BackendOperationFailed,
5349                            "managed session environment resolution failed",
5350                        ));
5351                    }
5352                    Err(
5353                        SessionEnvironmentMaterializeError::InvalidRoot
5354                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5355                        | SessionEnvironmentMaterializeError::Filesystem(_)
5356                        | SessionEnvironmentMaterializeError::Record(_),
5357                    ) => {
5358                        self.mark_materialization_recovery_required(ownership.id());
5359                        return Err(failure(
5360                            NodeFailureCode::BackendOperationFailed,
5361                            "managed session environment resolution failed",
5362                        ));
5363                    }
5364                };
5365                let transport = match mode {
5366                    SessionMode::Pty => TransportKind::Pty,
5367                    SessionMode::Inline => TransportKind::Pipe,
5368                    SessionMode::Acp => TransportKind::Acp,
5369                };
5370                let overlay = match bundle_arguments {
5371                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5372                        NativeInstanceLaunchOverlay::new(
5373                            provider.clone(),
5374                            transport,
5375                            environment,
5376                            extra_args,
5377                        )
5378                        .map_err(|_| failure(
5379                            NodeFailureCode::BundleBindingMismatch,
5380                            "managed session bundle launch overlay is invalid",
5381                        ))?,
5382                    ),
5383                    None => PreparedNativeLaunchOverlay::Environment(
5384                        NativeLaunchEnvironmentOverlay::new(
5385                            provider.clone(),
5386                            transport,
5387                            environment,
5388                        )
5389                        .map_err(|_| {
5390                            failure(
5391                                NodeFailureCode::BackendOperationFailed,
5392                                "managed session environment overlay is invalid",
5393                            )
5394                        })?,
5395                    ),
5396                };
5397                Ok(Some((ownership.id().clone(), Some(overlay))))
5398            }
5399        }
5400    }
5401
5402    fn destroy_record_materialization(
5403        &self,
5404        record_id: &SessionRecordId,
5405    ) -> Result<bool, NodeFailure> {
5406        let ids = self
5407            .materializations
5408            .lock()
5409            .unwrap_or_else(|poisoned| poisoned.into_inner())
5410            .values()
5411            .filter_map(|record| {
5412                (record.owner()
5413                    == &MaterializationOwner::Record {
5414                        record_id: record_id.clone(),
5415                    })
5416                .then(|| record.id().clone())
5417            })
5418            .collect::<Vec<_>>();
5419        if ids.len() > 1 {
5420            return Err(failure(
5421                NodeFailureCode::BackendOperationFailed,
5422                "managed session has conflicting environment ownership",
5423            ));
5424        }
5425        let Some(id) = ids.first().cloned() else {
5426            return Ok(false);
5427        };
5428        let (terminal_record, mut cleanup_ownership) = {
5429            let _transaction = self
5430                .state_transaction
5431                .lock()
5432                .unwrap_or_else(|poisoned| poisoned.into_inner());
5433            let original_record = self.record(record_id)?;
5434            let mut terminal_record = original_record.clone();
5435            terminal_record.provider_session = None;
5436            terminal_record.active_session = None;
5437            terminal_record.state = ManagedSessionState::Unavailable;
5438            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5439            terminal_record.updated_at_unix_ms = unix_time_ms();
5440            let original_ownership = self
5441                .materializations
5442                .lock()
5443                .unwrap_or_else(|poisoned| poisoned.into_inner())
5444                .get(&id)
5445                .cloned()
5446                .ok_or_else(|| {
5447                    failure(
5448                        NodeFailureCode::BackendOperationFailed,
5449                        "managed session environment ownership disappeared during cleanup",
5450                    )
5451                })?;
5452            let mut cleanup_ownership = original_ownership.clone();
5453            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5454                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5455                    failure(
5456                        NodeFailureCode::BackendOperationFailed,
5457                        "managed session environment cleanup state is invalid",
5458                    )
5459                })?;
5460            }
5461            self.session_records
5462                .lock()
5463                .unwrap_or_else(|poisoned| poisoned.into_inner())
5464                .records
5465                .insert(record_id.clone(), terminal_record.clone());
5466            self.materializations
5467                .lock()
5468                .unwrap_or_else(|poisoned| poisoned.into_inner())
5469                .insert(id.clone(), cleanup_ownership.clone());
5470            if let Err(error) = self.persist_state_locked() {
5471                self.session_records
5472                    .lock()
5473                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5474                    .records
5475                    .insert(record_id.clone(), original_record);
5476                self.materializations
5477                    .lock()
5478                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5479                    .insert(id.clone(), original_ownership);
5480                return Err(persistence_failure(error));
5481            }
5482            (terminal_record, cleanup_ownership)
5483        };
5484        let cleanup = self
5485            .session_environment_materializer
5486            .as_ref()
5487            .ok_or(())
5488            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5489        if cleanup.is_err() {
5490            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5491            self.materializations
5492                .lock()
5493                .unwrap_or_else(|poisoned| poisoned.into_inner())
5494                .insert(id.clone(), cleanup_ownership);
5495            if self.persist_state().is_err() {
5496                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5497            }
5498            return Err(failure(
5499                NodeFailureCode::BackendOperationFailed,
5500                "managed session environment cleanup requires recovery",
5501            ));
5502        }
5503        let _transaction = self
5504            .state_transaction
5505            .lock()
5506            .unwrap_or_else(|poisoned| poisoned.into_inner());
5507        self.remove_record_memory(record_id);
5508        self.materializations
5509            .lock()
5510            .unwrap_or_else(|poisoned| poisoned.into_inner())
5511            .remove(&id);
5512        if let Err(error) = self.persist_state_locked() {
5513            self.session_records
5514                .lock()
5515                .unwrap_or_else(|poisoned| poisoned.into_inner())
5516                .records
5517                .insert(record_id.clone(), terminal_record);
5518            self.materializations
5519                .lock()
5520                .unwrap_or_else(|poisoned| poisoned.into_inner())
5521                .insert(id, cleanup_ownership);
5522            return Err(persistence_failure(error));
5523        }
5524        Ok(true)
5525    }
5526
5527    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5528        let changed = {
5529            let mut materializations = self
5530                .materializations
5531                .lock()
5532                .unwrap_or_else(|poisoned| poisoned.into_inner());
5533            let Some(ownership) = materializations.get_mut(id) else {
5534                return;
5535            };
5536            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5537        };
5538        if changed && self.persist_state().is_err() {
5539            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5540        }
5541    }
5542
5543    fn reconcile_materializations(&self) {
5544        let records = self
5545            .materializations
5546            .lock()
5547            .unwrap_or_else(|poisoned| poisoned.into_inner())
5548            .values()
5549            .cloned()
5550            .collect::<Vec<_>>();
5551        for mut ownership in records {
5552            let result = match (ownership.state(), ownership.owner()) {
5553                (MaterializationState::Preparing, _)
5554                | (MaterializationState::CleanupRequired, _) => {
5555                    self.cleanup_materialization(ownership.id())
5556                }
5557                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5558                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5559                        self.materializations
5560                            .lock()
5561                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5562                            .insert(ownership.id().clone(), ownership.clone());
5563                        self.persist_state().map_err(persistence_failure)
5564                    } else {
5565                        Err(failure(
5566                            NodeFailureCode::BackendOperationFailed,
5567                            "session environment reconciliation failed",
5568                        ))
5569                    }
5570                }
5571                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5572                    .session_environment_materializer
5573                    .as_ref()
5574                    .ok_or_else(|| {
5575                        failure(
5576                            NodeFailureCode::BackendOperationFailed,
5577                            "session environment materializer is unavailable",
5578                        )
5579                    })
5580                    .and_then(|materializer| {
5581                        materializer.revalidate(&ownership).map_err(|_| failure(
5582                            NodeFailureCode::BackendOperationFailed,
5583                            "session materialization revalidation failed",
5584                        ))?;
5585                        let record = self.record(record_id)?;
5586                        if record.environment_profile.as_ref() != ownership.environment_profile()
5587                            || record.bundle.as_ref() != ownership.bundle()
5588                            || record.context.as_ref() != ownership.context()
5589                        {
5590                            return Err(failure(
5591                                NodeFailureCode::BackendOperationFailed,
5592                                "session materialization ownership changed after restart",
5593                            ));
5594                        }
5595                        if let Some(receipt) = ownership.bundle() {
5596                            let catalog = self.bundle_catalog.read()
5597                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5598                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5599                                NodeFailureCode::UnknownBundle,
5600                                "session bundle is unavailable after restart",
5601                            ))?;
5602                            if bundle.receipt() != *receipt {
5603                                return Err(failure(
5604                                    NodeFailureCode::BundleBindingMismatch,
5605                                    "session bundle changed after restart",
5606                                ));
5607                            }
5608                            let layout = self.bundle_layout(
5609                                &record.provider,
5610                                record.mode,
5611                                record.environment_profile.as_ref(),
5612                                bundle,
5613                            )?;
5614                            materializer
5615                                .revalidate_bundle(&ownership, bundle, layout)
5616                                .map_err(|_| failure(
5617                                    NodeFailureCode::BundleMaterializationFailed,
5618                                    "session bundle revalidation failed",
5619                                ))?;
5620                        }
5621                        if let Some(receipt) = ownership.context() {
5622                            let pack = materializer
5623                                .revalidate_context(&ownership, receipt)
5624                                .map_err(|_| {
5625                                    failure(
5626                                        NodeFailureCode::ContextPackMaterializationFailed,
5627                                        "session context revalidation failed",
5628                                    )
5629                                })?;
5630                            self.context_catalog
5631                                .write()
5632                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5633                                .insert(pack)
5634                                .map_err(|_| {
5635                                    failure(
5636                                        NodeFailureCode::ContextPackMaterializationFailed,
5637                                        "session context catalog restore failed",
5638                                    )
5639                                })?;
5640                        }
5641                        Ok(())
5642                    }),
5643                (MaterializationState::RecoveryRequired, _) => Ok(()),
5644            };
5645            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5646                let _ = ownership.mark_recovery_required(unix_time_ms());
5647                self.materializations
5648                    .lock()
5649                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5650                    .insert(ownership.id().clone(), ownership);
5651                if self.persist_state().is_err() {
5652                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5653                }
5654            }
5655        }
5656        let materialized_profiles = self
5657            .environment_materialization_profiles
5658            .read()
5659            .unwrap_or_else(|poisoned| poisoned.into_inner())
5660            .keys()
5661            .cloned()
5662            .collect::<Vec<_>>();
5663        let ownership = self
5664            .materializations
5665            .lock()
5666            .unwrap_or_else(|poisoned| poisoned.into_inner())
5667            .values()
5668            .cloned()
5669            .collect::<Vec<_>>();
5670        let changed = {
5671            let mut session_records = self
5672                .session_records
5673                .lock()
5674                .unwrap_or_else(|poisoned| poisoned.into_inner());
5675            let mut changed = false;
5676            for record in session_records.records.values_mut() {
5677                let environment_profile = record.environment_profile.as_ref();
5678                if environment_profile.is_none()
5679                    && record.bundle.is_none()
5680                    && record.context.is_none()
5681                {
5682                    continue;
5683                }
5684                if record.bundle.is_none()
5685                    && environment_profile.is_some_and(|profile| {
5686                        !materialized_profiles.contains(&profile.profile_id)
5687                    })
5688                {
5689                    continue;
5690                }
5691                let has_exact_ownership = ownership.iter().any(|candidate| {
5692                    candidate.state() == MaterializationState::Ready
5693                        && candidate.environment_profile() == environment_profile
5694                        && candidate.bundle() == record.bundle.as_ref()
5695                        && candidate.context() == record.context.as_ref()
5696                        && candidate.owner()
5697                            == &MaterializationOwner::Record {
5698                                record_id: record.record_id.clone(),
5699                            }
5700                });
5701                if !has_exact_ownership {
5702                    record.provider_session = None;
5703                    record.active_session = None;
5704                    record.state = ManagedSessionState::Unavailable;
5705                    record.last_error = Some(if record.bundle.is_some() {
5706                        "bundle-unavailable".to_owned()
5707                    } else if record.context.is_some() {
5708                        "context-unavailable".to_owned()
5709                    } else {
5710                        "environment-profile-unavailable".to_owned()
5711                    });
5712                    record.updated_at_unix_ms = unix_time_ms();
5713                    changed = true;
5714                }
5715            }
5716            changed
5717        };
5718        if changed && self.persist_state().is_err() {
5719            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5720        }
5721    }
5722
5723    fn replay_spawn_spec(
5724        &self,
5725        spec: &SpawnSpec,
5726        now: Instant,
5727    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5728        let mut cache = self
5729            .spawn_idempotency
5730            .lock()
5731            .unwrap_or_else(|poisoned| poisoned.into_inner());
5732        cache.entries.retain(|_, entry| entry.expires_at > now);
5733        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5734            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5735                return Err(failure(
5736                    NodeFailureCode::SpawnIdempotencyConflict,
5737                    "spawn idempotency key was reused across spawn kinds",
5738                ));
5739            };
5740            if existing != spec {
5741                return Err(failure(
5742                    NodeFailureCode::SpawnIdempotencyConflict,
5743                    "spawn idempotency key was reused with a different specification",
5744                ));
5745            }
5746            return Ok(Some(result.clone()));
5747        }
5748        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5749            return Err(failure(
5750                NodeFailureCode::SpawnIdempotencyCapacity,
5751                "spawn idempotency cache reached its bounded capacity",
5752            ));
5753        }
5754        Ok(None)
5755    }
5756
5757    fn remember_spawn_spec(
5758        &self,
5759        spec: SpawnSpec,
5760        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5761        accepted_at: Instant,
5762    ) {
5763        self
5764            .spawn_idempotency
5765            .lock()
5766            .unwrap_or_else(|poisoned| poisoned.into_inner())
5767            .entries
5768            .insert(
5769                spec.idempotency_key.clone(),
5770                SpawnIdempotencyEntry {
5771                    value: SpawnIdempotencyValue::Standard { spec, result },
5772                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5773                },
5774            );
5775    }
5776
5777    fn claim_harness_mcp_spawn(
5778        &self,
5779        _reservation_id: &HarnessMcpReservationId,
5780        _activation_digest: &HarnessMcpActivationDigest,
5781        spec: &SpawnSpec,
5782        now: Instant,
5783    ) -> Result<(), NodeFailure> {
5784        let mut cache = self.spawn_idempotency.lock()
5785            .unwrap_or_else(|poisoned| poisoned.into_inner());
5786        cache.entries.retain(|_, entry| entry.expires_at > now);
5787        if cache.entries.contains_key(&spec.idempotency_key) {
5788            return Err(failure(
5789                NodeFailureCode::SpawnIdempotencyConflict,
5790                "spawn idempotency key was reused across spawn kinds",
5791            ));
5792        }
5793        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5794            return Err(failure(
5795                NodeFailureCode::SpawnIdempotencyCapacity,
5796                "spawn idempotency cache reached its bounded capacity",
5797            ));
5798        }
5799        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5800            value: SpawnIdempotencyValue::HarnessMcp,
5801            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5802        });
5803        Ok(())
5804    }
5805
5806    fn replay_managed_spawn(
5807        &self,
5808        request: &ManagedWorktreeSpawnRequest,
5809        now: Instant,
5810    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5811        let mut cache = self.spawn_idempotency.lock()
5812            .unwrap_or_else(|poisoned| poisoned.into_inner());
5813        cache.entries.retain(|_, entry| entry.expires_at > now);
5814        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5815            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5816                return Err(failure(
5817                    NodeFailureCode::SpawnIdempotencyConflict,
5818                    "spawn idempotency key was reused across spawn kinds",
5819                ));
5820            };
5821            if existing != request {
5822                return Err(failure(
5823                    NodeFailureCode::SpawnIdempotencyConflict,
5824                    "spawn idempotency key was reused with a different managed specification",
5825                ));
5826            }
5827            return Ok(Some(result.clone()));
5828        }
5829        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5830            return Err(failure(
5831                NodeFailureCode::SpawnIdempotencyCapacity,
5832                "spawn idempotency cache reached its bounded capacity",
5833            ));
5834        }
5835        Ok(None)
5836    }
5837
5838    fn remember_managed_spawn(
5839        &self,
5840        request: ManagedWorktreeSpawnRequest,
5841        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5842        accepted_at: Instant,
5843    ) {
5844        self.spawn_idempotency.lock()
5845            .unwrap_or_else(|poisoned| poisoned.into_inner())
5846            .entries
5847            .insert(
5848                request.spawn_spec.idempotency_key.clone(),
5849                SpawnIdempotencyEntry {
5850                    value: SpawnIdempotencyValue::Managed { request, result },
5851                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5852                },
5853            );
5854    }
5855
5856    fn remember_managed_spawn_attempt(
5857        &self,
5858        durable_v2_key: Option<&SpawnIdempotencyKey>,
5859        request: ManagedWorktreeSpawnRequest,
5860        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5861        accepted_at: Instant,
5862    ) {
5863        if durable_v2_key.is_none() {
5864            self.remember_managed_spawn(request, result, accepted_at);
5865        }
5866    }
5867
5868    fn acquire_durable_managed_spawn_v2(
5869        &self,
5870        request: &ManagedWorktreeSpawnRequestV2,
5871        request_digest: String,
5872    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5873        if self.state_path.is_none() {
5874            return Err(failure(
5875                NodeFailureCode::BackendOperationFailed,
5876                "managed worktree V2 requires durable node state",
5877            ));
5878        }
5879        let _transaction = self.state_transaction
5880            .lock()
5881            .unwrap_or_else(|poisoned| poisoned.into_inner());
5882        let key = request.spawn_spec.idempotency_key.clone();
5883        let mut previous = None;
5884        {
5885            let mut replays = self.managed_spawn_replays
5886                .lock()
5887                .unwrap_or_else(|poisoned| poisoned.into_inner());
5888            if let Some(record) = replays.get_mut(&key) {
5889                if record.request_digest != request_digest {
5890                    return Err(failure(
5891                        NodeFailureCode::SpawnIdempotencyConflict,
5892                        "managed worktree V2 idempotency key was reused with a different request",
5893                    ));
5894                }
5895                match &record.state {
5896                    ManagedWorktreeSpawnReplayStateV10::Pending {
5897                        owner_incarnation_id,
5898                        lease_id: None,
5899                    } if owner_incarnation_id != &self.incarnation_id => {
5900                        previous = Some(record.clone());
5901                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5902                            owner_incarnation_id: self.incarnation_id,
5903                            lease_id: None,
5904                        };
5905                        record.updated_at_unix_ms = unix_time_ms();
5906                    }
5907                    ManagedWorktreeSpawnReplayStateV10::Pending {
5908                        lease_id: None,
5909                        ..
5910                    } => {
5911                        return Err(failure(
5912                            NodeFailureCode::BackendBusy,
5913                            "managed worktree V2 reservation is already active",
5914                        ));
5915                    }
5916                    ManagedWorktreeSpawnReplayStateV10::Pending {
5917                        lease_id: Some(_),
5918                        ..
5919                    } => {
5920                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5921                    }
5922                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5923                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5924                            receipt.clone(),
5925                        ));
5926                    }
5927                }
5928            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5929                return Err(failure(
5930                    NodeFailureCode::SpawnIdempotencyCapacity,
5931                    "durable managed worktree replay ledger reached its bounded capacity",
5932                ));
5933            } else {
5934                let now = unix_time_ms();
5935                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5936                    idempotency_key: key.clone(),
5937                    request_digest,
5938                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5939                    profile_id: request.worktree_profile_id.clone(),
5940                    expected_profile_revision: request.expected_profile_revision.clone(),
5941                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5942                        owner_incarnation_id: self.incarnation_id,
5943                        lease_id: None,
5944                    },
5945                    created_at_unix_ms: now,
5946                    updated_at_unix_ms: now,
5947                });
5948            }
5949        }
5950        if let Err(error) = self.persist_state_locked() {
5951            let mut replays = self.managed_spawn_replays
5952                .lock()
5953                .unwrap_or_else(|poisoned| poisoned.into_inner());
5954            match previous {
5955                Some(previous) => {
5956                    replays.insert(key, previous);
5957                }
5958                None => {
5959                    replays.remove(&key);
5960                }
5961            }
5962            return Err(persistence_failure(error));
5963        }
5964        Ok(DurableManagedSpawnReplayDecision::Reserved)
5965    }
5966
5967    fn link_durable_managed_spawn_v2_lease_locked(
5968        &self,
5969        key: &SpawnIdempotencyKey,
5970        lease_id: &ManagedWorktreeLeaseId,
5971    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5972        let mut replays = self.managed_spawn_replays
5973            .lock()
5974            .unwrap_or_else(|poisoned| poisoned.into_inner());
5975        let record = replays.get_mut(key).ok_or_else(|| {
5976            failure(
5977                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5978                "managed worktree V2 reservation disappeared before allocation",
5979            )
5980        })?;
5981        let previous = record.clone();
5982        match &mut record.state {
5983            ManagedWorktreeSpawnReplayStateV10::Pending {
5984                lease_id: current,
5985                ..
5986            }
5987                if current.is_none() =>
5988            {
5989                *current = Some(lease_id.clone());
5990                record.updated_at_unix_ms = unix_time_ms();
5991                Ok(previous)
5992            }
5993            _ => Err(failure(
5994                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5995                "managed worktree V2 reservation is not allocation-ready",
5996            )),
5997        }
5998    }
5999
6000    fn restore_durable_managed_spawn_v2_locked(
6001        &self,
6002        key: &SpawnIdempotencyKey,
6003        previous: ManagedWorktreeSpawnReplayRecordV10,
6004    ) {
6005        self.managed_spawn_replays
6006            .lock()
6007            .unwrap_or_else(|poisoned| poisoned.into_inner())
6008            .insert(key.clone(), previous);
6009    }
6010
6011    fn commit_durable_managed_spawn_v2(
6012        &self,
6013        request: &ManagedWorktreeSpawnRequestV2,
6014        receipt: ManagedWorktreeSpawnReceipt,
6015    ) -> Result<(), NodeFailure> {
6016        let _transaction = self.state_transaction
6017            .lock()
6018            .unwrap_or_else(|poisoned| poisoned.into_inner());
6019        let key = &request.spawn_spec.idempotency_key;
6020        let previous = {
6021            let mut replays = self.managed_spawn_replays
6022                .lock()
6023                .unwrap_or_else(|poisoned| poisoned.into_inner());
6024            let record = replays.get_mut(key).ok_or_else(|| {
6025                failure(
6026                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6027                    "managed worktree V2 reservation disappeared before commit",
6028                )
6029            })?;
6030            let previous = record.clone();
6031            match &record.state {
6032                ManagedWorktreeSpawnReplayStateV10::Pending {
6033                    lease_id: Some(lease_id),
6034                    ..
6035                } if lease_id == &receipt.lease.lease_id => {}
6036                _ => {
6037                    return Err(failure(
6038                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6039                        "managed worktree V2 reservation is not linked to the spawned lease",
6040                    ));
6041                }
6042            }
6043            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6044            record.updated_at_unix_ms = unix_time_ms();
6045            previous
6046        };
6047        if let Err(error) = self.persist_state_locked() {
6048            self.restore_durable_managed_spawn_v2_locked(key, previous);
6049            return Err(persistence_failure(error));
6050        }
6051        Ok(())
6052    }
6053
6054    fn remove_unallocated_durable_managed_spawn_v2(
6055        &self,
6056        request: &ManagedWorktreeSpawnRequestV2,
6057    ) -> Result<(), NodeFailure> {
6058        let _transaction = self.state_transaction
6059            .lock()
6060            .unwrap_or_else(|poisoned| poisoned.into_inner());
6061        let key = &request.spawn_spec.idempotency_key;
6062        let removed = {
6063            let mut replays = self.managed_spawn_replays
6064                .lock()
6065                .unwrap_or_else(|poisoned| poisoned.into_inner());
6066            match replays.get(key).map(|record| &record.state) {
6067                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6068                    lease_id: None,
6069                    ..
6070                }) => {
6071                    replays.remove(key)
6072                }
6073                _ => None,
6074            }
6075        };
6076        let Some(removed) = removed else {
6077            return Ok(());
6078        };
6079        if let Err(error) = self.persist_state_locked() {
6080            self.managed_spawn_replays
6081                .lock()
6082                .unwrap_or_else(|poisoned| poisoned.into_inner())
6083                .insert(key.clone(), removed);
6084            return Err(persistence_failure(error));
6085        }
6086        Ok(())
6087    }
6088
6089    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6090        if spec.target.node_id != self.node_id {
6091            return Err(failure(
6092                NodeFailureCode::SpawnTargetMismatch,
6093                "spawn target does not match this node",
6094            ));
6095        }
6096        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6097            failure(
6098                NodeFailureCode::UnknownSpawnProfile,
6099                "spawn profile is unavailable on this node",
6100            )
6101        })?;
6102        let resolved = spec.resolve(defaults).map_err(|error| match error {
6103            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6104                NodeFailureCode::SpawnProfileRevisionMismatch,
6105                "spawn profile revision does not match the loaded profile",
6106            ),
6107            _ => failure(
6108                NodeFailureCode::InvalidRequest,
6109                "spawn specification could not be resolved",
6110            ),
6111        })?;
6112        if resolved.target.worktree_id.is_some() {
6113            self.require_worktree_service(&resolved.target.workspace_id)?;
6114        }
6115        let environment_profile = self.resolve_environment_profile(&resolved)?;
6116        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6117        self.resolve_context(&resolved)?;
6118        Ok(resolved)
6119    }
6120
6121    async fn resolve_spawn_workspace(
6122        &self,
6123        resolved: &ResolvedSpawnSpec,
6124        deadline: Instant,
6125    ) -> Result<WorkspaceId, NodeFailure> {
6126        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6127            return Ok(resolved.target.workspace_id.clone());
6128        };
6129        if self.managed_worktrees.lock()
6130            .unwrap_or_else(|poisoned| poisoned.into_inner())
6131            .lease_for_workspace(worktree_id)
6132            .is_some()
6133        {
6134            return Err(failure(
6135                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6136                "managed worktree targets may only be spawned through their lease request",
6137            ));
6138        }
6139        self.require_worktree_service(&resolved.target.workspace_id)?;
6140        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6141        let selected_root = self.workspace_root(worktree_id)?;
6142        let remaining = spawn_deadline_remaining(deadline)?;
6143        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6144            .await
6145            .map_err(|_| {
6146                failure(
6147                    NodeFailureCode::SpawnDeadlineExceeded,
6148                    "Git worktree selection exceeded the spawn deadline",
6149                )
6150            })?
6151            .map_err(git_worktree_failure)?;
6152        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6153        Ok(worktree_id.clone())
6154    }
6155
6156    async fn spawn_from_spec(
6157        &self,
6158        spec: SpawnSpec,
6159    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6160        self.spawn_from_spec_with_proxy(spec, None).await
6161    }
6162
6163    async fn spawn_from_spec_with_proxy(
6164        &self,
6165        spec: SpawnSpec,
6166        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6167    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6168        let accepted_at = Instant::now();
6169        if harness_mcp.is_none() {
6170            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6171                return replayed;
6172            }
6173        }
6174        let resolved = self.resolve_spawn_spec(&spec)?;
6175        let environment_profile = self.resolve_environment_profile(&resolved)?;
6176        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6177        let context = self.resolve_context(&resolved)?;
6178        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6179        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6180        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6181        let spawn_workspace_id = match spawn_workspace_id {
6182            Ok(workspace_id) => workspace_id,
6183            Err(error) => {
6184                let result = Err(error);
6185                if harness_mcp.is_none() {
6186                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6187                }
6188                return result;
6189            }
6190        };
6191        if self.managed_worktrees.lock()
6192            .unwrap_or_else(|poisoned| poisoned.into_inner())
6193            .lease_for_workspace(&spawn_workspace_id)
6194            .is_some()
6195        {
6196            let result = Err(failure(
6197                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6198                "managed worktree targets may only be spawned through their lease request",
6199            ));
6200            if harness_mcp.is_none() {
6201                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6202            }
6203            return result;
6204        }
6205        let result = self
6206            .spawn_session_with_deadline(
6207                spawn_workspace_id,
6208                resolved.provider.clone(),
6209                resolved.mode,
6210                resolved.terminal_size,
6211                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6212                environment_profile.clone(),
6213                bundle.clone(),
6214                context.clone(),
6215                None,
6216                None,
6217                SpawnRecordPolicy::Always,
6218                Some(deadline),
6219                &required_capabilities,
6220                harness_mcp,
6221                resolved.approval_level,
6222                resolved.network_allowlist.clone(),
6223                resolved.browser_profile_id.clone(),
6224            )
6225            .await
6226            .map(|(session, _runtime_policy)| {
6227                resolved.receipt_with_materialization(
6228                    self.incarnation_id,
6229                    session,
6230                    environment_profile,
6231                    bundle,
6232                    context,
6233                )
6234            });
6235        if harness_mcp.is_none() {
6236            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6237        }
6238        result
6239    }
6240
6241    async fn spawn_from_spec_with_harness_mcp(
6242        &self,
6243        reservation_id: HarnessMcpReservationId,
6244        activation_digest: HarnessMcpActivationDigest,
6245        spec: SpawnSpec,
6246        deadline_unix_ms: u64,
6247    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6248        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6249            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6250        })?;
6251        if let Some(receipt) = registry
6252            .replay_spawn(&reservation_id, &activation_digest, &spec)
6253            .map_err(harness_mcp_failure)?
6254        {
6255            return Ok(receipt);
6256        }
6257        let resolved = self.resolve_spawn_spec(&spec)?;
6258        let prepared = registry
6259            .prepare_spawn(
6260                &reservation_id,
6261                &activation_digest,
6262                &spec,
6263                deadline_unix_ms,
6264                resolved.provider.clone(),
6265            )
6266            .map_err(harness_mcp_failure)?;
6267        self.claim_harness_mcp_spawn(
6268            &reservation_id,
6269            &activation_digest,
6270            &spec,
6271            Instant::now(),
6272        )?;
6273        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6274        let mut receipt = match spawn {
6275            Ok(receipt) => receipt,
6276            Err(error) => {
6277                let _ = registry.abort(&reservation_id, &activation_digest);
6278                return Err(error);
6279            }
6280        };
6281        let record_id = self
6282            .session_bindings
6283            .lock()
6284            .unwrap_or_else(|poisoned| poisoned.into_inner())
6285            .get(&receipt.session.session.instance_id)
6286            .and_then(|binding| binding.record_id.clone());
6287        let record_id = match record_id {
6288            Some(record_id) => record_id,
6289            None => {
6290                let rollback = self.remove_session(&receipt.session).await;
6291                let _ = registry.abort(&reservation_id, &activation_digest);
6292                if let Err(rollback_error) = rollback {
6293                    return Err(rollback_error);
6294                }
6295                return Err(failure(
6296                    NodeFailureCode::BindingMismatch,
6297                    "harness MCP spawn did not retain its exact managed record",
6298                ));
6299            }
6300        };
6301        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6302            reservation_id: reservation_id.clone(),
6303            activation_digest: activation_digest.clone(),
6304        });
6305        if let Err(error) = registry.mark_spawned(
6306            &prepared,
6307            receipt.session.clone(),
6308            record_id,
6309            receipt.clone(),
6310        ) {
6311            let rollback = self.remove_session(&receipt.session).await;
6312            let _ = registry.abort(&reservation_id, &activation_digest);
6313            if let Err(rollback_error) = rollback {
6314                return Err(rollback_error);
6315            }
6316            return Err(harness_mcp_failure(error));
6317        }
6318        Ok(receipt)
6319    }
6320
6321    async fn spawn_managed_worktree(
6322        &self,
6323        request: ManagedWorktreeSpawnRequest,
6324    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6325        self.spawn_managed_worktree_inner(request, None).await
6326    }
6327
6328    async fn spawn_managed_worktree_inner(
6329        &self,
6330        request: ManagedWorktreeSpawnRequest,
6331        durable_v2_key: Option<&SpawnIdempotencyKey>,
6332    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6333        let accepted_at = Instant::now();
6334        if durable_v2_key.is_none() {
6335            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6336                return replayed;
6337            }
6338        }
6339        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6340        if request.spawn_spec.target.worktree_id.is_some() {
6341            let result = Err(failure(
6342                NodeFailureCode::InvalidRequest,
6343                "managed worktree spawn must not select a caller-provided worktree",
6344            ));
6345            self.remember_managed_spawn_attempt(
6346                durable_v2_key,
6347                request,
6348                result.clone(),
6349                accepted_at,
6350            );
6351            return result;
6352        }
6353        let environment_profile = self.resolve_environment_profile(&resolved)?;
6354        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6355        let context = self.resolve_context(&resolved)?;
6356        let source_workspace_id = resolved.target.workspace_id.clone();
6357        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6358        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6359        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6360        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6361            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6362            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6363            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6364            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6365        };
6366        let runtime_policy = timeout(
6367            spawn_deadline_remaining(deadline)?,
6368            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6369        ).await.map_err(|_| failure(
6370            NodeFailureCode::SpawnDeadlineExceeded,
6371            "provider admission exceeded the managed spawn deadline",
6372        ))??;
6373        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6374            return Err(failure(
6375                NodeFailureCode::UnsupportedSpawnCapability,
6376                "provider runtime does not admit a required spawn capability",
6377            ));
6378        }
6379        self.ensure_binding_capacity()?;
6380        let source_root = self.workspace_root(&source_workspace_id)?;
6381        profile.validate_for_workspace(&source_root)
6382            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6383        let base_timeout_ms = u64::try_from(
6384            spawn_deadline_remaining(deadline)?.as_millis(),
6385        ).unwrap_or(u64::MAX).max(1);
6386        let base_commit = resolve_base_commit_with_timeout(
6387            &source_root,
6388            profile.base(),
6389            base_timeout_ms,
6390        ).await.map_err(|error| {
6391            if Instant::now() >= deadline || error.message.contains("timed out") {
6392                failure(
6393                    NodeFailureCode::SpawnDeadlineExceeded,
6394                    "managed worktree base resolution exceeded the spawn deadline",
6395                )
6396            } else {
6397                managed_git_worktree_failure(error)
6398            }
6399        })?;
6400
6401        let lease = {
6402            let _transaction = self.state_transaction.lock()
6403                .unwrap_or_else(|poisoned| poisoned.into_inner());
6404            let previous = self.managed_worktrees.lock()
6405                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6406            let lease = self.managed_worktrees.lock()
6407                .unwrap_or_else(|poisoned| poisoned.into_inner())
6408                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6409                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6410            let previous_replay = match durable_v2_key
6411                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6412                .transpose()
6413            {
6414                Ok(previous) => previous,
6415                Err(error) => {
6416                    *self.managed_worktrees.lock()
6417                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6418                    return Err(error);
6419                }
6420            };
6421            if let Err(error) = self.persist_state_locked() {
6422                *self.managed_worktrees.lock()
6423                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6424                if let (Some(key), Some(previous_replay)) =
6425                    (durable_v2_key, previous_replay)
6426                {
6427                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6428                }
6429                return Err(persistence_failure(error));
6430            }
6431            lease
6432        };
6433        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6434        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6435            self.managed_worktrees.lock()
6436                .unwrap_or_else(|poisoned| poisoned.into_inner())
6437                .remove_unmutated(&lease.lease_id);
6438            if self.persist_state().is_err() {
6439                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6440            }
6441            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6442            self.remember_managed_spawn_attempt(
6443                durable_v2_key,
6444                request,
6445                result.clone(),
6446                accepted_at,
6447            );
6448            return result;
6449        }
6450        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6451            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6452            Err(error) => {
6453                self.reconcile_failed_allocation(&lease, &source_root).await;
6454                let result = Err(error);
6455                self.remember_managed_spawn_attempt(
6456                    durable_v2_key,
6457                    request,
6458                    result.clone(),
6459                    accepted_at,
6460                );
6461                return result;
6462            }
6463        };
6464        let created = match create_git_worktree_with_timeout(
6465                &source_root,
6466                &lease.target_root,
6467                &lease.branch,
6468                Some(&base_commit),
6469                mutation_timeout_ms,
6470            ).await {
6471            Ok(created) => created,
6472            Err(error) => {
6473                self.reconcile_failed_allocation(&lease, &source_root).await;
6474                let result = Err(if Instant::now() >= deadline
6475                    || error.message.contains("timed out")
6476                    || error.message.contains("deadline")
6477                {
6478                    failure(
6479                        NodeFailureCode::SpawnDeadlineExceeded,
6480                        "managed worktree creation exceeded the spawn deadline",
6481                    )
6482                } else {
6483                    managed_git_worktree_failure(error)
6484                });
6485                self.remember_managed_spawn_attempt(
6486                    durable_v2_key,
6487                    request,
6488                    result.clone(),
6489                    accepted_at,
6490                );
6491                return result;
6492            }
6493        };
6494        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6495            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6496            let result = Err(failure(
6497                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6498                "created worktree identity did not match its durable lease",
6499            ));
6500            self.remember_managed_spawn_attempt(
6501                durable_v2_key,
6502                request,
6503                result.clone(),
6504                accepted_at,
6505            );
6506            return result;
6507        }
6508        {
6509            let mut registry = self.managed_worktrees.lock()
6510                .unwrap_or_else(|poisoned| poisoned.into_inner());
6511            let current = registry.get_mut(&lease.lease_id)
6512                .expect("allocated managed worktree lease remains present");
6513            current.expected_head = Some(created.head.clone());
6514            current.state = ManagedWorktreeLeaseState::Ready;
6515            current.updated_at_unix_ms = unix_time_ms();
6516        }
6517        if let Err(error) = self.persist_state() {
6518            self.mark_managed_recovery_required(
6519                &lease.lease_id,
6520                ManagedWorktreeCleanupFailure::Backend,
6521            );
6522            let result = Err(persistence_failure(error));
6523            self.remember_managed_spawn_attempt(
6524                durable_v2_key,
6525                request,
6526                result.clone(),
6527                accepted_at,
6528            );
6529            return result;
6530        }
6531        let ready = self.managed_worktrees.lock()
6532            .unwrap_or_else(|poisoned| poisoned.into_inner())
6533            .get(&lease.lease_id)
6534            .expect("ready managed worktree lease remains present")
6535            .snapshot();
6536        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6537        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6538            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6539            let result = Err(error);
6540            self.remember_managed_spawn_attempt(
6541                durable_v2_key,
6542                request,
6543                result.clone(),
6544                accepted_at,
6545            );
6546            return result;
6547        }
6548        let spawn = self.spawn_session_with_deadline(
6549            lease.workspace_id.clone(),
6550            resolved.provider.clone(),
6551            resolved.mode,
6552            resolved.terminal_size,
6553            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6554            environment_profile.clone(),
6555            bundle.clone(),
6556            context.clone(),
6557            Some(lease.lease_id.clone()),
6558            Some(runtime_policy),
6559            SpawnRecordPolicy::Always,
6560            Some(deadline),
6561            &required_capabilities,
6562            None,
6563            resolved.approval_level,
6564            resolved.network_allowlist.clone(),
6565            resolved.browser_profile_id.clone(),
6566        ).await;
6567        let (session, effective_runtime_policy) = match spawn {
6568            Ok(spawned) => spawned,
6569            Err(error) => {
6570                if self
6571                    .cleanup_managed_worktree(&lease.lease_id, true)
6572                    .await
6573                    .is_err()
6574                {
6575                    self.mark_managed_recovery_required(
6576                        &lease.lease_id,
6577                        ManagedWorktreeCleanupFailure::Backend,
6578                    );
6579                }
6580                let result = Err(error);
6581                self.remember_managed_spawn_attempt(
6582                    durable_v2_key,
6583                    request,
6584                    result.clone(),
6585                    accepted_at,
6586                );
6587                return result;
6588            }
6589        };
6590        let (record_id, raw_inventory_record_id) = {
6591            let mut bindings = self.session_bindings.lock()
6592                .unwrap_or_else(|poisoned| poisoned.into_inner());
6593            let binding = bindings.get_mut(&session.session.instance_id)
6594                .expect("successful spawn retains its session binding");
6595            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6596            managed_spawn_record_ownership(binding, effective_runtime_policy)
6597        };
6598        let bound = self.managed_worktrees.lock()
6599            .unwrap_or_else(|poisoned| poisoned.into_inner())
6600            .bind_session(
6601                &lease.lease_id,
6602                ManagedWorktreeSessionHolder {
6603                    incarnation_id: self.incarnation_id,
6604                    instance_id: session.session.instance_id,
6605                    generation: session.session.generation,
6606                },
6607                record_id,
6608                unix_time_ms(),
6609            )
6610            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6611        let snapshot = match bound {
6612            Ok(snapshot) => snapshot,
6613            Err(error) => {
6614                if raw_inventory_record_id
6615                    .as_ref()
6616                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6617                {
6618                    self.mark_managed_recovery_required(
6619                        &lease.lease_id,
6620                        ManagedWorktreeCleanupFailure::Backend,
6621                    );
6622                }
6623                let _ = self.remove_session(&session).await;
6624                self.mark_managed_recovery_required(
6625                    &lease.lease_id,
6626                    ManagedWorktreeCleanupFailure::Backend,
6627                );
6628                let result = Err(error);
6629                self.remember_managed_spawn_attempt(
6630                    durable_v2_key,
6631                    request,
6632                    result.clone(),
6633                    accepted_at,
6634                );
6635                return result;
6636            }
6637        };
6638        if let Err(error) = self.persist_state() {
6639            if raw_inventory_record_id
6640                .as_ref()
6641                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6642            {
6643                self.mark_managed_recovery_required(
6644                    &lease.lease_id,
6645                    ManagedWorktreeCleanupFailure::Backend,
6646                );
6647            }
6648            let _ = self.remove_session(&session).await;
6649            if self
6650                .cleanup_managed_worktree(&lease.lease_id, true)
6651                .await
6652                .is_err()
6653            {
6654                self.mark_managed_recovery_required(
6655                    &lease.lease_id,
6656                    ManagedWorktreeCleanupFailure::Backend,
6657                );
6658            }
6659            let result = Err(persistence_failure(error));
6660            self.remember_managed_spawn_attempt(
6661                durable_v2_key,
6662                request,
6663                result.clone(),
6664                accepted_at,
6665            );
6666            return result;
6667        }
6668        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6669        let receipt = managed_spawn_receipt(
6670            &resolved,
6671            self.incarnation_id,
6672            session,
6673            snapshot,
6674            environment_profile,
6675            bundle,
6676            context,
6677        );
6678        let result = Ok(receipt);
6679        self.remember_managed_spawn_attempt(
6680            durable_v2_key,
6681            request,
6682            result.clone(),
6683            accepted_at,
6684        );
6685        result
6686    }
6687
6688    async fn spawn_managed_worktree_v2(
6689        &self,
6690        request: ManagedWorktreeSpawnRequestV2,
6691    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6692        if request.spawn_spec.target.node_id != self.node_id {
6693            return Err(failure(
6694                NodeFailureCode::SpawnTargetMismatch,
6695                "spawn target does not match this node",
6696            ));
6697        }
6698        if request.spawn_spec.target.worktree_id.is_some() {
6699            return Err(failure(
6700                NodeFailureCode::InvalidRequest,
6701                "managed worktree spawn must not select a caller-provided worktree",
6702            ));
6703        }
6704        let request_digest = managed_spawn_request_digest_v2(&request)?;
6705        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6706        match replay {
6707            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6708            DurableManagedSpawnReplayDecision::PendingLinked => {
6709                return Err(failure(
6710                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6711                    "managed worktree V2 reservation has an incomplete durable allocation",
6712                ));
6713            }
6714            DurableManagedSpawnReplayDecision::Reserved => {}
6715        }
6716        let validated = (|| {
6717            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6718            let profile = self.managed_profile(
6719                &resolved.target.workspace_id,
6720                &request.worktree_profile_id,
6721            )?;
6722            if profile.revision() != &request.expected_profile_revision {
6723                return Err(failure(
6724                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6725                    "managed worktree profile revision does not match the loaded profile",
6726                ));
6727            }
6728            Ok(())
6729        })();
6730        match validated {
6731            Ok(()) => {}
6732            Err(error) => {
6733                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6734                return Err(error);
6735            }
6736        }
6737        let legacy = ManagedWorktreeSpawnRequest {
6738            spawn_spec: request.spawn_spec.clone(),
6739            worktree_profile_id: request.worktree_profile_id.clone(),
6740        };
6741        match self
6742            .spawn_managed_worktree_inner(
6743                legacy,
6744                Some(&request.spawn_spec.idempotency_key),
6745            )
6746            .await
6747        {
6748            Ok(receipt) => {
6749                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6750                Ok(receipt)
6751            }
6752            Err(error) => {
6753                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6754                Err(error)
6755            }
6756        }
6757    }
6758
6759    fn managed_profile(
6760        &self,
6761        workspace_id: &WorkspaceId,
6762        profile_id: &WorktreeProfileId,
6763    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6764        if self.worktree_service_modes.get(workspace_id).copied()
6765            != Some(WorktreeServiceMode::Managed)
6766        {
6767            return Err(failure(
6768                NodeFailureCode::UnsupportedSpawnCapability,
6769                "workspace does not enable managed worktrees",
6770            ));
6771        }
6772        self.managed_worktree_profiles.get(workspace_id)
6773            .and_then(|profiles| profiles.get(profile_id))
6774            .cloned()
6775            .ok_or_else(|| failure(
6776                NodeFailureCode::InvalidRequest,
6777                "managed worktree profile is unavailable for this workspace",
6778            ))
6779    }
6780
6781    async fn reconcile_failed_allocation(
6782        &self,
6783        lease: &ManagedWorktreeLeaseRecord,
6784        source_root: &str,
6785    ) {
6786        match list_git_worktrees(source_root).await {
6787            Ok(worktrees) => {
6788                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6789                    None => {
6790                        match std::fs::symlink_metadata(&lease.target_root) {
6791                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6792                                self.managed_worktrees.lock()
6793                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6794                                    .remove_unmutated(&lease.lease_id);
6795                                let _ = self.persist_state();
6796                            }
6797                            _ => self.mark_managed_recovery_required(
6798                                &lease.lease_id,
6799                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6800                            ),
6801                        }
6802                    }
6803                    Some(item) if exact_owned_worktree(
6804                        lease,
6805                        &item.path,
6806                        item.branch.as_deref(),
6807                        &item.head,
6808                    ) => {
6809                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6810                    }
6811                    Some(_) => self.mark_managed_recovery_required(
6812                        &lease.lease_id,
6813                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6814                    ),
6815                }
6816            }
6817            Err(_) => self.mark_managed_recovery_required(
6818                &lease.lease_id,
6819                ManagedWorktreeCleanupFailure::Backend,
6820            ),
6821        }
6822    }
6823
6824    async fn reconcile_managed_worktrees(&self) {
6825        let leases = self.managed_worktrees.lock()
6826            .unwrap_or_else(|poisoned| poisoned.into_inner())
6827            .active_records()
6828            .cloned()
6829            .collect::<Vec<_>>();
6830        for lease in leases {
6831            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6832                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6833                _ => {
6834                    self.mark_managed_recovery_required(
6835                        &lease.lease_id,
6836                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6837                    );
6838                    continue;
6839                }
6840            };
6841            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6842                self.mark_managed_recovery_required(
6843                    &lease.lease_id,
6844                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6845                );
6846                continue;
6847            }
6848            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6849                Ok(root) => root,
6850                Err(_) => {
6851                    self.mark_managed_recovery_required(
6852                        &lease.lease_id,
6853                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6854                    );
6855                    continue;
6856                }
6857            };
6858            let worktrees = match list_git_worktrees(&source_root).await {
6859                Ok(worktrees) => worktrees,
6860                Err(_) => {
6861                    self.mark_managed_recovery_required(
6862                        &lease.lease_id,
6863                        ManagedWorktreeCleanupFailure::Backend,
6864                    );
6865                    continue;
6866                }
6867            };
6868            let listed = worktrees.iter()
6869                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6870            let Some(listed) = listed else {
6871                match std::fs::symlink_metadata(&lease.target_root) {
6872                    Err(error) if error.kind() == io::ErrorKind::NotFound
6873                        && lease.state == ManagedWorktreeLeaseState::Allocating
6874                        && !lease.has_holders() => {
6875                            let removed = self.managed_worktrees.lock()
6876                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6877                                .tombstone(&lease.lease_id, unix_time_ms());
6878                            if self.persist_state().is_ok() && removed.is_some() {
6879                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6880                                    lease_id: lease.lease_id.clone(),
6881                                });
6882                            }
6883                        }
6884                    _ => self.mark_managed_recovery_required(
6885                        &lease.lease_id,
6886                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6887                    ),
6888                }
6889                continue;
6890            };
6891            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6892                &lease,
6893                &listed.path,
6894                listed.branch.as_deref(),
6895                &listed.head,
6896            ) {
6897                self.mark_managed_recovery_required(
6898                    &lease.lease_id,
6899                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6900                );
6901                continue;
6902            }
6903            if !self.workspaces.read()
6904                .unwrap_or_else(|poisoned| poisoned.into_inner())
6905                .contains_key(&lease.workspace_id)
6906            {
6907                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6908                    self.mark_managed_recovery_required(
6909                        &lease.lease_id,
6910                        ManagedWorktreeCleanupFailure::Backend,
6911                    );
6912                    continue;
6913                }
6914            }
6915            let snapshot = {
6916                let mut registry = self.managed_worktrees.lock()
6917                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6918                let current = registry.get_mut(&lease.lease_id)
6919                    .expect("reconciled managed lease remains present");
6920                current.expected_head = Some(listed.head.clone());
6921                current.cleanup_failure = None;
6922                current.state = if current.has_holders() {
6923                    ManagedWorktreeLeaseState::InUse
6924                } else if current.retention == ManagedWorktreeRetention::Retain {
6925                    ManagedWorktreeLeaseState::Retained
6926                } else {
6927                    ManagedWorktreeLeaseState::Ready
6928                };
6929                current.updated_at_unix_ms = unix_time_ms();
6930                current.snapshot()
6931            };
6932            if self.persist_state().is_err() {
6933                self.mark_managed_recovery_required(
6934                    &lease.lease_id,
6935                    ManagedWorktreeCleanupFailure::Backend,
6936                );
6937                continue;
6938            }
6939            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6940            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6941                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6942            }
6943        }
6944    }
6945
6946    /// Crash-recovery startup sweep: durably capture the pack for every
6947    /// `Live` managed record whose most recent session has not yet produced
6948    /// one (recovers a session whose `Exited{0}` event was observed but the
6949    /// Node crashed before the export finished). The reactive fast path for
6950    /// a single just-exited record does not go through this — see
6951    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6952    /// it can capture the exact `SessionAddress` before `publish_control`
6953    /// clears it, rather than re-deriving it from (by then likely already
6954    /// `Dormant`) record state the way a rescan naturally would.
6955    ///
6956    /// Returns as soon as every eligible record's export has been *spawned*,
6957    /// not completed — each one runs on its own detached task (see
6958    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6959    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6960    /// point this is called from `NodeServer::run()`, so nothing could ever
6961    /// drain the history round trip if this awaited it inline here either).
6962    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6963    /// is a silent skip for THIS record — an unusable source or a racing
6964    /// record is not surfaced to any caller.
6965    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6966        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6967            .session_records
6968            .lock()
6969            .unwrap_or_else(|poisoned| poisoned.into_inner())
6970            .records
6971            .values()
6972            .filter(|record| {
6973                record.state == ManagedSessionState::Live
6974                    && record.exported_context.is_none()
6975                    && record.provider_session.is_some()
6976            })
6977            .filter_map(|record| {
6978                record
6979                    .active_session
6980                    .clone()
6981                    .map(|session| (record.record_id.clone(), session))
6982            })
6983            .collect();
6984        for (record_id, session) in candidates {
6985            let shared = Arc::clone(self);
6986            tokio::spawn(async move {
6987                shared
6988                    .reconcile_context_pack_export_for_record(&record_id, session)
6989                    .await;
6990            });
6991        }
6992    }
6993
6994    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
6995    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
6996    /// round trip this performs (`discover_history`/`load_history`, via
6997    /// `export_context_pack_for_session_record`) can only ever settle once
6998    /// `NativeRuntime::tick()` runs again to drain the authority worker's
6999    /// completion — and `tick()` is exclusively owned by, and only ever
7000    /// called from, that same drive-loop task. Awaiting this inline there
7001    /// would starve `tick()` and this call could never observe its own
7002    /// command complete.
7003    ///
7004    /// Because this now genuinely races the drive loop's own
7005    /// `publish_control` (which downgrades this exact record from `Live` to
7006    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7007    /// triggered this reconcile), the record legitimately may already be
7008    /// `Dormant` by the time this reads it, or may transition mid-flight —
7009    /// `allow_clean_detachment: true` on the downstream export call is what
7010    /// tolerates exactly that transition (and only that transition; see
7011    /// `session_record_export_target_matches`).
7012    async fn reconcile_context_pack_export_for_record(
7013        &self,
7014        record_id: &SessionRecordId,
7015        session: SessionAddress,
7016    ) {
7017        let Ok(record) = self.record(record_id) else {
7018            return;
7019        };
7020        if !matches!(
7021            record.state,
7022            ManagedSessionState::Live | ManagedSessionState::Dormant
7023        ) || record.exported_context.is_some()
7024            || record.provider_session.is_none()
7025        {
7026            return;
7027        }
7028        // Pack bytes are already durably committed inside this call, via
7029        // commit_context_pack_for_session_record's own durable
7030        // write-through (context_pack_store) — no separate step here.
7031        // Unclean exits (non-zero/forced/no loadable history) are already
7032        // rejected by context_pack_source_status_is_usable inside
7033        // materialize_context_pack: this simply returns Err and is skipped.
7034        let Ok(receipt) = self
7035            .export_context_pack_for_session_record(&record.record_id, &session, true)
7036            .await
7037        else {
7038            return;
7039        };
7040        self.set_exported_context(&record.record_id, receipt);
7041    }
7042
7043    /// Records the record's most recent clean-exit pack. No-op (not an
7044    /// error) if the record disappeared, or if it already carries an
7045    /// `exported_context` — first-write-wins, matching the harness-side
7046    /// idempotent reconciliation that consumes this field.
7047    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7048        let transaction = self
7049            .state_transaction
7050            .lock()
7051            .unwrap_or_else(|poisoned| poisoned.into_inner());
7052        let (previous, updated) = {
7053            let mut records = self
7054                .session_records
7055                .lock()
7056                .unwrap_or_else(|poisoned| poisoned.into_inner());
7057            let Some(record) = records.records.get_mut(record_id) else {
7058                return;
7059            };
7060            if record.exported_context.is_some() {
7061                return;
7062            }
7063            let previous = record.clone();
7064            record.exported_context = Some(receipt);
7065            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7066            (previous, record.clone())
7067        };
7068        if self.persist_state_locked().is_err() {
7069            self.session_records
7070                .lock()
7071                .unwrap_or_else(|poisoned| poisoned.into_inner())
7072                .records
7073                .insert(record_id.clone(), previous);
7074            return;
7075        }
7076        drop(transaction);
7077        self.publish_record(updated);
7078    }
7079
7080    /// Resolves the managed record and its exact `SessionAddress` currently
7081    /// bound to `instance_id` at exactly `generation`, used by the reactive
7082    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7083    /// record without a full rescan. Mirrors the lookup already inlined in
7084    /// `publish_control`. Captured synchronously, before `publish_control`
7085    /// for the same event clears the record's own `active_session` — the
7086    /// caller passes the resolved address through to a detached
7087    /// reconciliation task rather than re-deriving it from the record later,
7088    /// after that race has necessarily already happened.
7089    fn managed_record_export_target_for_instance(
7090        &self,
7091        instance_id: AgentInstanceId,
7092        generation: SessionGeneration,
7093    ) -> Option<(SessionRecordId, SessionAddress)> {
7094        let bindings = self
7095            .session_bindings
7096            .lock()
7097            .unwrap_or_else(|poisoned| poisoned.into_inner());
7098        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7099        let record_id = binding.record_id.clone()?;
7100        Some((
7101            record_id,
7102            SessionAddress {
7103                workspace_id: binding.workspace_id.clone(),
7104                session: SessionKey { instance_id, generation },
7105            },
7106        ))
7107    }
7108
7109    fn mark_managed_recovery_required(
7110        &self,
7111        lease_id: &ManagedWorktreeLeaseId,
7112        failure_kind: ManagedWorktreeCleanupFailure,
7113    ) {
7114        let snapshot = {
7115            let mut registry = self.managed_worktrees.lock()
7116                .unwrap_or_else(|poisoned| poisoned.into_inner());
7117            let Some(lease) = registry.get_mut(lease_id) else { return };
7118            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7119            lease.cleanup_failure = Some(failure_kind);
7120            lease.updated_at_unix_ms = unix_time_ms();
7121            lease.snapshot()
7122        };
7123        let _ = self.persist_state();
7124        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7125    }
7126
7127    fn set_persistence_error(&self, error: Option<String>) {
7128        *self
7129            .persistence_error
7130            .write()
7131            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7132            .map(|message| session_registry::sanitized_persistence_summary(&message));
7133    }
7134
7135    fn persist_state(&self) -> io::Result<()> {
7136        let _transaction = self
7137            .state_transaction
7138            .lock()
7139            .unwrap_or_else(|poisoned| poisoned.into_inner());
7140        self.persist_state_locked()
7141    }
7142
7143    fn persist_state_locked(&self) -> io::Result<()> {
7144        let workspaces = self
7145            .workspaces
7146            .read()
7147            .unwrap_or_else(|poisoned| poisoned.into_inner())
7148            .clone();
7149        let records = self
7150            .session_records
7151            .lock()
7152            .unwrap_or_else(|poisoned| poisoned.into_inner())
7153            .records
7154            .values()
7155            .cloned()
7156            .collect::<Vec<_>>();
7157        let mut managed = self.managed_worktrees
7158            .lock()
7159            .unwrap_or_else(|poisoned| poisoned.into_inner());
7160        managed.reattach_record_holders(
7161            &managed_worktree_record_holders(&records),
7162            unix_time_ms(),
7163        );
7164        let materializations = self
7165            .materializations
7166            .lock()
7167            .unwrap_or_else(|poisoned| poisoned.into_inner())
7168            .values()
7169            .cloned()
7170            .collect::<Vec<_>>();
7171        let managed_spawn_replays = self.managed_spawn_replays
7172            .lock()
7173            .unwrap_or_else(|poisoned| poisoned.into_inner())
7174            .values()
7175            .cloned()
7176            .collect::<Vec<_>>();
7177        let result = session_registry::save_v10(
7178            self.state_path.as_deref(),
7179            &self.node_id,
7180            &workspaces,
7181            &records,
7182            &managed.records(),
7183            &managed.tombstones(),
7184            &materializations,
7185            &managed_spawn_replays,
7186        );
7187        match &result {
7188            Ok(warning) => self.set_persistence_error(warning.clone()),
7189            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7190        }
7191        result.map(|_| ())
7192    }
7193
7194    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7195        let _mutation_guard = self.mutation_gate.lock().await;
7196        self.begin_shutdown_locked().await
7197    }
7198
7199    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7200        if self.shutdown.swap(true, Ordering::AcqRel) {
7201            return Ok(());
7202        }
7203        SessionLifecycleHook::on_shutdown(self);
7204        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7205        let mut first_error = None;
7206        for session in &self.handle.snapshot().sessions {
7207            let remaining = deadline.saturating_duration_since(Instant::now());
7208            if remaining.is_zero() {
7209                first_error.get_or_insert_with(|| {
7210                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7211                });
7212                break;
7213            }
7214            if let Err(error) = self
7215                .dispatch_bounded(
7216                    ControlCommand::Stop {
7217                        instance_id: session.instance_id,
7218                        force: true,
7219                    },
7220                    remaining,
7221                )
7222                .await
7223            {
7224                first_error.get_or_insert(error);
7225            }
7226        }
7227        self.shutdown_notify.notify_waiters();
7228        match first_error {
7229            Some(error) => Err(error),
7230            None => Ok(()),
7231        }
7232    }
7233
7234    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7235        let bindings = self
7236            .session_bindings
7237            .lock()
7238            .unwrap_or_else(|poisoned| poisoned.into_inner());
7239        if bindings.len() >= CONTROL_SESSIONS_MAX {
7240            Err(failure(
7241                NodeFailureCode::BackendBusy,
7242                "node session-binding capacity is full; remove an existing session first",
7243            ))
7244        } else {
7245            Ok(())
7246        }
7247    }
7248
7249    /// One-owner workspace/env collision guard: a live session on
7250    /// `workspace_id` with a different `environment_profile` (option
7251    /// equality on the resolved receipt, including Some vs None) blocks
7252    /// another spawn on that same cwd. Same profile may still share the
7253    /// workspace; distinct profiles need distinct workspace ids (managed
7254    /// worktree).
7255    fn ensure_workspace_environment_profile_compatible(
7256        &self,
7257        workspace_id: &WorkspaceId,
7258        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7259    ) -> Result<(), NodeFailure> {
7260        let bindings = self
7261            .session_bindings
7262            .lock()
7263            .unwrap_or_else(|poisoned| poisoned.into_inner());
7264        for binding in bindings.values() {
7265            if &binding.workspace_id != workspace_id {
7266                continue;
7267            }
7268            if binding.environment_profile.as_ref() != environment_profile {
7269                return Err(failure(
7270                    NodeFailureCode::WorkspaceBusy,
7271                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7272                ));
7273            }
7274        }
7275        Ok(())
7276    }
7277
7278    #[cfg(test)]
7279    fn bind_session(
7280        &self,
7281        instance_id: AgentInstanceId,
7282        workspace_id: WorkspaceId,
7283        generation: SessionGeneration,
7284    ) {
7285        self.bind_session_with_policy(
7286            &SessionAddress {
7287                workspace_id,
7288                session: SessionKey { instance_id, generation },
7289            },
7290            ProviderRuntimePolicy::raw_pty(),
7291            None,
7292        );
7293    }
7294
7295    #[cfg(test)]
7296    fn bind_session_with_policy(
7297        &self,
7298        address: &SessionAddress,
7299        runtime_policy: ProviderRuntimePolicy,
7300        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7301    ) {
7302        self.bind_session_with_materialization(
7303            address,
7304            runtime_policy,
7305            environment_profile,
7306            None,
7307            None,
7308            None,
7309        );
7310    }
7311
7312    fn bind_session_with_materialization(
7313        &self,
7314        address: &SessionAddress,
7315        runtime_policy: ProviderRuntimePolicy,
7316        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7317        bundle: Option<ResolvedBundleReceipt>,
7318        context: Option<ResolvedContextPackReceipt>,
7319        materialization_id: Option<MaterializationId>,
7320    ) {
7321        let mut bindings = self
7322            .session_bindings
7323            .lock()
7324            .unwrap_or_else(|poisoned| poisoned.into_inner());
7325        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7326        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7327        bindings.insert(address.session.instance_id, SessionBinding {
7328            workspace_id: address.workspace_id.clone(),
7329            generation: address.session.generation,
7330            runtime_policy,
7331            pending_resume: None,
7332            record_id: None,
7333            managed_worktree_lease_id: None,
7334            environment_profile,
7335            bundle,
7336            context,
7337            materialization_id,
7338        });
7339    }
7340
7341    #[cfg(test)]
7342    fn bind_managed_session(
7343        &self,
7344        address: &SessionAddress,
7345        record_id: SessionRecordId,
7346        runtime_policy: ProviderRuntimePolicy,
7347        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7348    ) {
7349        self.bind_managed_session_with_materialization(
7350            address,
7351            record_id,
7352            runtime_policy,
7353            environment_profile,
7354            None,
7355            None,
7356            None,
7357        );
7358    }
7359
7360    fn bind_managed_session_with_materialization(
7361        &self,
7362        address: &SessionAddress,
7363        record_id: SessionRecordId,
7364        runtime_policy: ProviderRuntimePolicy,
7365        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7366        bundle: Option<ResolvedBundleReceipt>,
7367        context: Option<ResolvedContextPackReceipt>,
7368        materialization_id: Option<MaterializationId>,
7369    ) {
7370        let mut bindings = self
7371            .session_bindings
7372            .lock()
7373            .unwrap_or_else(|poisoned| poisoned.into_inner());
7374        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7375        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7376        bindings.insert(
7377            address.session.instance_id,
7378            SessionBinding {
7379                workspace_id: address.workspace_id.clone(),
7380                generation: address.session.generation,
7381                runtime_policy,
7382                pending_resume: None,
7383                record_id: Some(record_id),
7384                managed_worktree_lease_id: None,
7385                environment_profile,
7386                bundle,
7387                context,
7388                materialization_id,
7389            },
7390        );
7391    }
7392
7393    #[cfg(test)]
7394    fn bind_spawn_session(
7395        &self,
7396        address: &SessionAddress,
7397        provider: AgentId,
7398        mode: SessionMode,
7399        runtime_policy: ProviderRuntimePolicy,
7400        record_policy: SpawnRecordPolicy,
7401        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7402    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7403        self.bind_spawn_session_with_materialization(
7404            address,
7405            provider,
7406            mode,
7407            runtime_policy,
7408            record_policy,
7409            environment_profile,
7410            None,
7411            None,
7412            None,
7413        )
7414    }
7415
7416    fn bind_spawn_session_with_materialization(
7417        &self,
7418        address: &SessionAddress,
7419        provider: AgentId,
7420        mode: SessionMode,
7421        runtime_policy: ProviderRuntimePolicy,
7422        record_policy: SpawnRecordPolicy,
7423        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7424        bundle: Option<ResolvedBundleReceipt>,
7425        context: Option<ResolvedContextPackReceipt>,
7426        materialization_id: Option<MaterializationId>,
7427    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7428        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7429            && !runtime_policy.provider_session_identity
7430        {
7431            self.bind_session_with_materialization(
7432                address,
7433                runtime_policy,
7434                environment_profile,
7435                bundle,
7436                context,
7437                materialization_id,
7438            );
7439            return Ok(None);
7440        }
7441        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7442        let record = self.new_record(
7443            address,
7444            provider,
7445            mode,
7446            active_record_state(runtime_policy.provider_session_identity, false),
7447            None,
7448            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7449            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7450            record_owns_lifecycle.then(|| context.clone()).flatten(),
7451        )?;
7452        let record_id = record.record_id.clone();
7453        let transaction = self
7454            .state_transaction
7455            .lock()
7456            .unwrap_or_else(|poisoned| poisoned.into_inner());
7457        self.insert_record(record.clone())?;
7458        let previous_materialization = if record_owns_lifecycle {
7459            if let Some(materialization_id) = materialization_id.as_ref() {
7460                let mut materializations = self
7461                    .materializations
7462                    .lock()
7463                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7464                let Some(ownership) = materializations.get_mut(materialization_id) else {
7465                    drop(materializations);
7466                    self.remove_record_memory(&record_id);
7467                    return Err(failure(
7468                        NodeFailureCode::BackendOperationFailed,
7469                        "session environment ownership disappeared before binding",
7470                    ));
7471                };
7472                let previous = ownership.clone();
7473                if ownership
7474                    .transfer_to_record(record_id.clone(), unix_time_ms())
7475                    .is_err()
7476                {
7477                    drop(materializations);
7478                    self.remove_record_memory(&record_id);
7479                    return Err(failure(
7480                        NodeFailureCode::BackendOperationFailed,
7481                        "session environment ownership transfer failed",
7482                    ));
7483                }
7484                Some(previous)
7485            } else {
7486                None
7487            }
7488        } else {
7489            None
7490        };
7491        self.bind_managed_session_with_materialization(
7492            address,
7493            record_id.clone(),
7494            runtime_policy,
7495            environment_profile,
7496            bundle,
7497            context,
7498            materialization_id.clone(),
7499        );
7500        if let Err(error) = self.persist_state_locked() {
7501            self.remove_binding(address);
7502            self.remove_record_memory(&record_id);
7503            if let (Some(materialization_id), Some(previous)) =
7504                (materialization_id.as_ref(), previous_materialization)
7505            {
7506                self.materializations
7507                    .lock()
7508                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7509                    .insert(materialization_id.clone(), previous);
7510            }
7511            return Err(persistence_failure(error));
7512        }
7513        drop(transaction);
7514        self.publish_record(record);
7515        Ok(Some(record_id))
7516    }
7517
7518    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7519        for _ in 0..8 {
7520            let bytes = random_nonce().map_err(|error| {
7521                failure(
7522                    NodeFailureCode::BackendOperationFailed,
7523                    &format!("session record identity generation failed: {error}"),
7524                )
7525            })?;
7526            let mut value = String::with_capacity(27);
7527            value.push_str("sr-");
7528            for byte in &bytes[..12] {
7529                use std::fmt::Write as _;
7530                let _ = write!(&mut value, "{byte:02x}");
7531            }
7532            let record_id = SessionRecordId::new(value).map_err(|error| {
7533                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7534            })?;
7535            let records = self
7536                .session_records
7537                .lock()
7538                .unwrap_or_else(|poisoned| poisoned.into_inner());
7539            if !records.records.contains_key(&record_id) {
7540                return Ok(record_id);
7541            }
7542        }
7543        Err(failure(
7544            NodeFailureCode::BackendBusy,
7545            "could not allocate a unique session record ID",
7546        ))
7547    }
7548
7549    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7550        for _ in 0..8 {
7551            let bytes = random_nonce().map_err(|error| {
7552                failure(
7553                    NodeFailureCode::BackendOperationFailed,
7554                    &format!("task identity generation failed: {error}"),
7555                )
7556            })?;
7557            let mut nonce = [0_u8; 12];
7558            nonce.copy_from_slice(&bytes[..12]);
7559            let task_id = TaskId::from_nonce(nonce);
7560            let records = self
7561                .session_records
7562                .lock()
7563                .unwrap_or_else(|poisoned| poisoned.into_inner());
7564            if !records.records.values().any(|record| {
7565                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7566                    == Some(&task_id)
7567            }) {
7568                return Ok(task_id);
7569            }
7570        }
7571        Err(failure(
7572            NodeFailureCode::BackendBusy,
7573            "could not allocate a unique task ID",
7574        ))
7575    }
7576
7577    fn default_record_name(&self, provider: &AgentId) -> String {
7578        let records = self
7579            .session_records
7580            .lock()
7581            .unwrap_or_else(|poisoned| poisoned.into_inner());
7582        let ordinal = records
7583            .records
7584            .values()
7585            .filter(|record| &record.provider == provider)
7586            .count()
7587            .saturating_add(1);
7588        format!("{} #{ordinal}", provider.as_str())
7589    }
7590
7591    fn new_record(
7592        &self,
7593        address: &SessionAddress,
7594        provider: AgentId,
7595        mode: SessionMode,
7596        state: ManagedSessionState,
7597        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7598        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7599        bundle: Option<ResolvedBundleReceipt>,
7600        context: Option<ResolvedContextPackReceipt>,
7601    ) -> Result<ManagedSessionRecord, NodeFailure> {
7602        let canonical_root = self.workspace_root(&address.workspace_id)?;
7603        let now = unix_time_ms();
7604        Ok(ManagedSessionRecord {
7605            record_id: self.allocate_record_id()?,
7606            display_name: self.default_record_name(&provider),
7607            provider,
7608            mode,
7609            state,
7610            workspace_id: address.workspace_id.clone(),
7611            canonical_root: opaque_windows_path(canonical_root),
7612            provider_session,
7613            active_session: Some(address.clone()),
7614            environment_profile,
7615            bundle,
7616            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7617            context,
7618            exported_context: None,
7619            task_binding: None,
7620            created_at_unix_ms: now,
7621            updated_at_unix_ms: now,
7622            last_error: None,
7623        })
7624    }
7625
7626    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7627        let mut records = self
7628            .session_records
7629            .lock()
7630            .unwrap_or_else(|poisoned| poisoned.into_inner());
7631        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7632            return Err(failure(
7633                NodeFailureCode::BackendBusy,
7634                "managed session record capacity is full; forget an inactive record first",
7635            ));
7636        }
7637        if let Some(identity) = record.provider_session.as_ref() {
7638            if records.records.values().any(|candidate| {
7639                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7640                    session_registry::same_provider_session(
7641                        &candidate.provider,
7642                        candidate_identity,
7643                        &record.provider,
7644                        identity,
7645                    )
7646                })
7647            }) {
7648                return Err(failure(
7649                    NodeFailureCode::SessionRecordConflict,
7650                    "provider session is already represented by another managed record",
7651                ));
7652            }
7653        }
7654        match records.records.entry(record.record_id.clone()) {
7655            std::collections::btree_map::Entry::Vacant(entry) => {
7656                entry.insert(record);
7657            }
7658            std::collections::btree_map::Entry::Occupied(_) => {
7659                return Err(failure(
7660                    NodeFailureCode::SessionRecordConflict,
7661                    "session record ID collision",
7662                ));
7663            }
7664        }
7665        Ok(())
7666    }
7667
7668    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7669        self.session_records
7670            .lock()
7671            .unwrap_or_else(|poisoned| poisoned.into_inner())
7672            .records
7673            .get(record_id)
7674            .cloned()
7675            .ok_or_else(|| {
7676                failure(
7677                    NodeFailureCode::UnknownSessionRecord,
7678                    "managed session record does not exist",
7679                )
7680            })
7681    }
7682
7683    fn revalidate_session_record_preview(
7684        &self,
7685        expected: &ManagedSessionRecord,
7686        identity: &ProviderSessionIdentity,
7687    ) -> Result<(), NodeFailure> {
7688        let current = self.record(&expected.record_id)?;
7689        let current_root = self.workspace_root(&current.workspace_id)?;
7690        if current.provider != expected.provider
7691            || current.workspace_id != expected.workspace_id
7692            || current.canonical_root != expected.canonical_root
7693            || current.provider_session.as_ref() != Some(identity)
7694            || !platform::roots_equal(
7695                &current_root,
7696                windows_path_text(&current.canonical_root),
7697            )
7698        {
7699            return Err(failure(
7700                NodeFailureCode::SessionRecordConflict,
7701                "managed session changed while its preview was loading",
7702            ));
7703        }
7704        Ok(())
7705    }
7706
7707    fn revalidate_session_record_context_export(
7708        &self,
7709        expected: &ManagedSessionRecord,
7710        identity: Option<&ProviderSessionIdentity>,
7711        session: &SessionAddress,
7712        allow_clean_detachment: bool,
7713    ) -> Result<(), NodeFailure> {
7714        let current = self.record(&expected.record_id)?;
7715        let current_root = self.workspace_root(&current.workspace_id)?;
7716        let snapshot = self.internal_session_snapshot(session)?;
7717        let binding = self
7718            .session_bindings
7719            .lock()
7720            .unwrap_or_else(|poisoned| poisoned.into_inner())
7721            .get(&session.session.instance_id)
7722            .cloned()
7723            .ok_or_else(|| {
7724                failure(
7725                    NodeFailureCode::SessionRecordConflict,
7726                    "managed session runtime binding disappeared during context export",
7727                )
7728            })?;
7729        if !session_record_context_export_binding_is_exact(
7730            expected,
7731            &current,
7732            identity,
7733            session,
7734            &binding,
7735            &snapshot.agent_id,
7736            &current_root,
7737            allow_clean_detachment,
7738        ) || !session_record_context_export_source_is_usable(
7739            &current,
7740            &snapshot.status,
7741            allow_clean_detachment,
7742        ) {
7743            return Err(failure(
7744                NodeFailureCode::SessionRecordConflict,
7745                "managed session binding changed during context export",
7746            ));
7747        }
7748        Ok(())
7749    }
7750
7751    fn revalidate_loaded_history(
7752        &self,
7753        session: &SessionAddress,
7754        candidate_id: &str,
7755        loaded: &HistorySessionRecord,
7756    ) -> Result<(), NodeFailure> {
7757        let snapshot = self.internal_session_snapshot(session)?;
7758        if snapshot.history.pending.is_some()
7759            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7760            || snapshot.history.loaded.as_ref() != Some(loaded)
7761        {
7762            return Err(failure(
7763                NodeFailureCode::SessionRecordConflict,
7764                "managed session history changed during context export",
7765            ));
7766        }
7767        Ok(())
7768    }
7769
7770    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7771        self.session_records
7772            .lock()
7773            .unwrap_or_else(|poisoned| poisoned.into_inner())
7774            .records
7775            .remove(record_id)
7776    }
7777
7778    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7779        if self.destroy_record_materialization(record_id)? {
7780            self.publish(NodeEvent::SessionRecordRemoved {
7781                record_id: record_id.clone(),
7782            });
7783            return Ok(());
7784        }
7785        let transaction = self
7786            .state_transaction
7787            .lock()
7788            .unwrap_or_else(|poisoned| poisoned.into_inner());
7789        let Some(removed) = self.remove_record_memory(record_id) else {
7790            return Ok(());
7791        };
7792        if let Err(error) = self.persist_state_locked() {
7793            let _ = self.insert_record(removed);
7794            return Err(persistence_failure(error));
7795        }
7796        drop(transaction);
7797        self.publish(NodeEvent::SessionRecordRemoved {
7798            record_id: record_id.clone(),
7799        });
7800        Ok(())
7801    }
7802
7803    fn publish_record(&self, record: ManagedSessionRecord) {
7804        self.publish(NodeEvent::SessionRecordUpserted { record });
7805    }
7806
7807    fn mark_record_error(
7808        &self,
7809        record_id: &SessionRecordId,
7810        message: &str,
7811    ) -> Result<(), NodeFailure> {
7812        let transaction = self
7813            .state_transaction
7814            .lock()
7815            .unwrap_or_else(|poisoned| poisoned.into_inner());
7816        let previous = {
7817            let mut records = self
7818                .session_records
7819                .lock()
7820                .unwrap_or_else(|poisoned| poisoned.into_inner());
7821            let Some(record) = records.records.get_mut(record_id) else {
7822                return Ok(());
7823            };
7824            let previous = record.clone();
7825            record.state = ManagedSessionState::Unavailable;
7826            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7827            record.updated_at_unix_ms = unix_time_ms();
7828            previous
7829        };
7830        if let Err(error) = self.persist_state_locked() {
7831            self.session_records
7832                .lock()
7833                .unwrap_or_else(|poisoned| poisoned.into_inner())
7834                .records
7835                .insert(record_id.clone(), previous);
7836            return Err(persistence_failure(error));
7837        }
7838        let record = self.record(record_id)?;
7839        drop(transaction);
7840        self.publish_record(record);
7841        Ok(())
7842    }
7843
7844    fn rename_session_record(
7845        &self,
7846        record_id: &SessionRecordId,
7847        display_name: String,
7848    ) -> Result<ManagedSessionRecord, NodeFailure> {
7849        validate_display_name(&display_name)
7850            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7851        let _transaction = self
7852            .state_transaction
7853            .lock()
7854            .unwrap_or_else(|poisoned| poisoned.into_inner());
7855        let previous = {
7856            let mut records = self
7857                .session_records
7858                .lock()
7859                .unwrap_or_else(|poisoned| poisoned.into_inner());
7860            let record = records.records.get_mut(record_id).ok_or_else(|| {
7861                failure(
7862                    NodeFailureCode::UnknownSessionRecord,
7863                    "managed session record does not exist",
7864                )
7865            })?;
7866            let previous = record.clone();
7867            record.display_name = display_name;
7868            record.updated_at_unix_ms = unix_time_ms();
7869            previous
7870        };
7871        if let Err(error) = self.persist_state_locked() {
7872            self.session_records
7873                .lock()
7874                .unwrap_or_else(|poisoned| poisoned.into_inner())
7875                .records
7876                .insert(record_id.clone(), previous);
7877            return Err(persistence_failure(error));
7878        }
7879        let updated = self.record(record_id)?;
7880        self.publish_record(updated.clone());
7881        Ok(updated)
7882    }
7883
7884    fn set_session_task(
7885        &self,
7886        record_id: &SessionRecordId,
7887        expected_revision: u64,
7888        target: SessionTaskTargetV1,
7889    ) -> Result<ManagedSessionRecord, NodeFailure> {
7890        let transaction = self
7891            .state_transaction
7892            .lock()
7893            .unwrap_or_else(|poisoned| poisoned.into_inner());
7894        let current = self.record(record_id)?;
7895        let current_task_id = current
7896            .task_binding
7897            .as_ref()
7898            .and_then(|binding| binding.task_id.as_ref());
7899        let current_revision = current
7900            .task_binding
7901            .as_ref()
7902            .map_or(0, |binding| binding.revision);
7903        if expected_revision != current_revision {
7904            return Err(failure(
7905                NodeFailureCode::SessionRecordConflict,
7906                "managed session task binding revision changed",
7907            ));
7908        }
7909        let exact = match &target {
7910            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7911            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7912            SessionTaskTargetV1::New => false,
7913        };
7914        if exact {
7915            return Ok(current);
7916        }
7917        let revision = current_revision.checked_add(1).ok_or_else(|| {
7918            failure(
7919                NodeFailureCode::SessionRecordConflict,
7920                "managed session task binding revision is exhausted",
7921            )
7922        })?;
7923        let task_id = match target {
7924            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7925            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7926            SessionTaskTargetV1::Clear => None,
7927        };
7928        let changed_at_unix_ms = unix_time_ms()
7929            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7930                failure(
7931                    NodeFailureCode::SessionRecordConflict,
7932                    "managed session record timestamp is exhausted",
7933                )
7934            })?);
7935        let updated = {
7936            let mut records = self
7937                .session_records
7938                .lock()
7939                .unwrap_or_else(|poisoned| poisoned.into_inner());
7940            let record = records.records.get_mut(record_id).ok_or_else(|| {
7941                failure(
7942                    NodeFailureCode::UnknownSessionRecord,
7943                    "managed session record does not exist",
7944                )
7945            })?;
7946            record.task_binding = Some(SessionTaskBindingV1 {
7947                revision,
7948                task_id,
7949                changed_at_unix_ms,
7950            });
7951            record.updated_at_unix_ms = changed_at_unix_ms;
7952            record.clone()
7953        };
7954        if let Err(error) = self.persist_state_locked() {
7955            self.session_records
7956                .lock()
7957                .unwrap_or_else(|poisoned| poisoned.into_inner())
7958                .records
7959                .insert(record_id.clone(), current);
7960            return Err(persistence_failure(error));
7961        }
7962        drop(transaction);
7963        self.publish_record(updated.clone());
7964        Ok(updated)
7965    }
7966
7967    fn index_provider_session(
7968        &self,
7969        workspace_id: WorkspaceId,
7970        provider: AgentId,
7971        identity: ProviderSessionIdentity,
7972        display_name: String,
7973    ) -> Result<ManagedSessionRecord, NodeFailure> {
7974        self.index_provider_session_with_policy(
7975            workspace_id,
7976            provider,
7977            identity,
7978            display_name,
7979            false,
7980        )
7981    }
7982
7983    fn index_provider_session_with_policy(
7984        &self,
7985        workspace_id: WorkspaceId,
7986        provider: AgentId,
7987        identity: ProviderSessionIdentity,
7988        display_name: String,
7989        allow_validated_transcript_path: bool,
7990    ) -> Result<ManagedSessionRecord, NodeFailure> {
7991        validate_display_name(&display_name)
7992            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7993        identity
7994            .validate()
7995            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7996        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7997            return Err(failure(
7998                NodeFailureCode::InvalidRequest,
7999                "provider session reference index v1 does not accept transcript paths",
8000            ));
8001        }
8002        let canonical_root = self.workspace_root(&workspace_id)?;
8003        if !self.enabled_providers.contains(&provider) {
8004            return Err(failure(
8005                NodeFailureCode::InvalidRequest,
8006                "provider is not enabled on this node",
8007            ));
8008        }
8009
8010        let transaction = self
8011            .state_transaction
8012            .lock()
8013            .unwrap_or_else(|poisoned| poisoned.into_inner());
8014        {
8015            let records = self
8016                .session_records
8017                .lock()
8018                .unwrap_or_else(|poisoned| poisoned.into_inner());
8019            if let Some(existing) = records.records.values().find(|candidate| {
8020                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8021                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8022                })
8023            }) {
8024                if existing.provider == provider && existing.workspace_id == workspace_id {
8025                    return Ok(existing.clone());
8026                }
8027                return Err(failure(
8028                    NodeFailureCode::SessionRecordConflict,
8029                    "provider session reference conflicts with another provider or workspace",
8030                ));
8031            }
8032            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8033                return Err(failure(
8034                    NodeFailureCode::BackendBusy,
8035                    "managed session record capacity is full; forget an inactive record first",
8036                ));
8037            }
8038        }
8039
8040        let now = unix_time_ms();
8041        let record = ManagedSessionRecord {
8042            record_id: self.allocate_record_id()?,
8043            display_name,
8044            provider,
8045            mode: SessionMode::Pty,
8046            state: ManagedSessionState::Dormant,
8047            workspace_id,
8048            canonical_root: opaque_windows_path(canonical_root),
8049            provider_session: Some(identity),
8050            active_session: None,
8051            environment_profile: None,
8052            bundle: None,
8053            context_id: None,
8054            context: None,
8055            exported_context: None,
8056            task_binding: None,
8057            created_at_unix_ms: now,
8058            updated_at_unix_ms: now,
8059            last_error: None,
8060        };
8061        self.insert_record(record.clone())?;
8062        if let Err(error) = self.persist_state_locked() {
8063            self.remove_record_memory(&record.record_id);
8064            return Err(persistence_failure(error));
8065        }
8066        drop(transaction);
8067        self.publish_record(record.clone());
8068        Ok(record)
8069    }
8070
8071    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8072        self.session_bindings
8073            .lock()
8074            .unwrap_or_else(|poisoned| poisoned.into_inner())
8075            .iter()
8076            .find_map(|(instance_id, binding)| {
8077                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8078                    workspace_id: binding.workspace_id.clone(),
8079                    session: SessionKey {
8080                        instance_id: *instance_id,
8081                        generation: binding.generation,
8082                    },
8083                })
8084            })
8085    }
8086
8087    async fn forget_session_record(
8088        &self,
8089        record_id: &SessionRecordId,
8090    ) -> Result<(), NodeFailure> {
8091        let record = self.record(record_id)?;
8092        if record.active_session.is_some()
8093            || matches!(
8094                record.state,
8095                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8096            )
8097        {
8098            return Err(failure(
8099                NodeFailureCode::SessionRecordBusy,
8100                "stop the managed session before forgetting it",
8101            ));
8102        }
8103        if let Some(address) = self.bound_address_for_record(record_id) {
8104            self.remove_session(&address).await?;
8105        }
8106        self.remove_managed_session_record(record_id, &record.workspace_id).await
8107    }
8108
8109    /// Removes one managed session record from durable state and notifies
8110    /// every listener -- the shared transaction body behind both
8111    /// `forget_session_record`'s operator-driven path (busy-checked above
8112    /// it, and clears any still-live session binding before calling in
8113    /// here) and the retention sweep (`retire_unavailable_session_records`,
8114    /// which only ever selects `ManagedSessionState::Unavailable` records
8115    /// that are already known never-busy and never bound, so it calls
8116    /// straight into this with neither check). Cleans a managed-worktree
8117    /// lease if the record held one, removes the record's environment
8118    /// materialization (or, absent one, removes it from memory directly),
8119    /// persists under `state_transaction` with rollback on persist failure,
8120    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8121    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8122    /// exactly as it does for an operator-driven forget.
8123    async fn remove_managed_session_record(
8124        &self,
8125        record_id: &SessionRecordId,
8126        workspace_id: &WorkspaceId,
8127    ) -> Result<(), NodeFailure> {
8128        let managed_lease_id = self.managed_worktrees.lock()
8129            .unwrap_or_else(|poisoned| poisoned.into_inner())
8130            .lease_for_workspace(workspace_id);
8131        if !self.destroy_record_materialization(record_id)? {
8132            let transaction = self
8133                .state_transaction
8134                .lock()
8135                .unwrap_or_else(|poisoned| poisoned.into_inner());
8136            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8137                failure(
8138                    NodeFailureCode::UnknownSessionRecord,
8139                    "managed session record does not exist",
8140                )
8141            })?;
8142            if let Err(error) = self.persist_state_locked() {
8143                let _ = self.insert_record(removed);
8144                return Err(persistence_failure(error));
8145            }
8146            drop(transaction);
8147        }
8148        self.publish(NodeEvent::SessionRecordRemoved {
8149            record_id: record_id.clone(),
8150        });
8151        if let Some(lease_id) = managed_lease_id {
8152            if let Some(snapshot) = self.managed_worktrees.lock()
8153                .unwrap_or_else(|poisoned| poisoned.into_inner())
8154                .get(&lease_id)
8155                .map(ManagedWorktreeLeaseRecord::snapshot)
8156            {
8157                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8158            }
8159            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8160        }
8161        Ok(())
8162    }
8163
8164    /// Retires `ManagedSessionState::Unavailable` records against the
8165    /// node's configured `session_record_retention` age/keep-N policy.
8166    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8167    /// `Dormant` is resumable and folding it into this sweep would delete
8168    /// records a caller can still act on. Both policy fields default to
8169    /// `0` (disabled); when both are `0` this is a no-op. When both are
8170    /// set, a record is retired only if it fails BOTH tests (older than
8171    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8172    /// workspace); when only one is set, that one alone decides. Called
8173    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8174    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8175    /// loop -- see both call sites.
8176    async fn retire_unavailable_session_records(&self) {
8177        let policy = self.session_record_retention;
8178        let age_enabled = policy.age_ms > 0;
8179        let keep_enabled = policy.keep_per_workspace > 0;
8180        if !age_enabled && !keep_enabled {
8181            return;
8182        }
8183        let now = unix_time_ms();
8184        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8185            self.session_records
8186                .lock()
8187                .unwrap_or_else(|poisoned| poisoned.into_inner())
8188                .records
8189                .values()
8190                .filter(|record| record.state == ManagedSessionState::Unavailable)
8191                .map(|record| {
8192                    (
8193                        record.record_id.clone(),
8194                        record.workspace_id.clone(),
8195                        record.provider.clone(),
8196                        record.updated_at_unix_ms,
8197                    )
8198                })
8199                .collect()
8200        };
8201        if candidates.is_empty() {
8202            return;
8203        }
8204        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8205            BTreeMap::new();
8206        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8207            by_workspace
8208                .entry(workspace_id)
8209                .or_default()
8210                .push((record_id, provider, updated_at_unix_ms));
8211        }
8212        let reason = if age_enabled && keep_enabled {
8213            "age+keep-n"
8214        } else if age_enabled {
8215            "age"
8216        } else {
8217            "keep-n"
8218        };
8219        let mut retired: u64 = 0;
8220        for (workspace_id, mut records) in by_workspace {
8221            // Newest `updated_at_unix_ms` first, so index `>=
8222            // keep_per_workspace` names exactly "beyond the newest N".
8223            records.sort_by(|left, right| right.2.cmp(&left.2));
8224            for (index, (record_id, provider, updated_at_unix_ms)) in
8225                records.into_iter().enumerate()
8226            {
8227                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8228                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8229                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8230                let should_retire = if age_enabled && keep_enabled {
8231                    age_hit && keep_hit
8232                } else {
8233                    age_hit || keep_hit
8234                };
8235                if !should_retire {
8236                    continue;
8237                }
8238                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8239                    Ok(()) => {
8240                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8241                        retired += 1;
8242                        tracing::info!(
8243                            node_id = %self.node_id,
8244                            record_id = %record_id,
8245                            workspace_id = %workspace_id,
8246                            provider = %provider,
8247                            age_ms = record_age_ms,
8248                            reason,
8249                            "managed session record retired"
8250                        );
8251                    }
8252                    Err(error) => tracing::warn!(
8253                        node_id = %self.node_id,
8254                        record_id = %record_id,
8255                        workspace_id = %workspace_id,
8256                        code = ?error.code,
8257                        cause = %error.message,
8258                        "session record retention sweep failed to retire a record"
8259                    ),
8260                }
8261            }
8262        }
8263        if retired > 0 {
8264            let remaining_unavailable = self
8265                .session_records
8266                .lock()
8267                .unwrap_or_else(|poisoned| poisoned.into_inner())
8268                .records
8269                .values()
8270                .filter(|record| record.state == ManagedSessionState::Unavailable)
8271                .count();
8272            tracing::info!(
8273                node_id = %self.node_id,
8274                retired,
8275                remaining_unavailable,
8276                "session record retention sweep completed"
8277            );
8278        }
8279    }
8280
8281    async fn resume_session_record(
8282        &self,
8283        record_id: &SessionRecordId,
8284        terminal_size: gate4agent_types::TerminalSize,
8285        initial_prompt: Option<String>,
8286    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8287        let record = self.record(record_id)?;
8288        if record.active_session.is_some()
8289            || matches!(
8290                record.state,
8291                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8292            )
8293        {
8294            return Err(failure(
8295                NodeFailureCode::SessionRecordBusy,
8296                "managed session already has a live runtime binding",
8297            ));
8298        }
8299        let identity = record.provider_session.clone().ok_or_else(|| {
8300            failure(
8301                NodeFailureCode::SessionRecordNotResumable,
8302                "managed session has no verified provider session identity",
8303            )
8304        })?;
8305        let working_directory = self.workspace_root(&record.workspace_id)?;
8306        if !platform::roots_equal(
8307            &working_directory,
8308            windows_path_text(&record.canonical_root),
8309        ) {
8310            return Err(failure(
8311                NodeFailureCode::SessionRecordConflict,
8312                "managed session workspace root changed; refusing to resume in another directory",
8313            ));
8314        }
8315        {
8316            let records = self
8317                .session_records
8318                .lock()
8319                .unwrap_or_else(|poisoned| poisoned.into_inner());
8320            if records.records.values().any(|candidate| {
8321                candidate.record_id != *record_id
8322                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8323                        session_registry::same_provider_session(
8324                            &candidate.provider,
8325                            candidate_identity,
8326                            &record.provider,
8327                            &identity,
8328                        )
8329                    })
8330                    && (candidate.active_session.is_some()
8331                        || matches!(
8332                            candidate.state,
8333                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8334                        ))
8335            }) {
8336                return Err(failure(
8337                    NodeFailureCode::SessionRecordConflict,
8338                    "provider session is already active through another managed record",
8339                ));
8340            }
8341        }
8342        let runtime_requirement = if initial_prompt.is_some() {
8343            ProviderRuntimeRequirement::ResumeWithPrompt
8344        } else {
8345            ProviderRuntimeRequirement::Resume
8346        };
8347        let runtime_policy = self.admit_provider_runtime(
8348            &record.provider,
8349            runtime_requirement,
8350        )
8351        .await?;
8352        let request = ResumeLaunchRequest {
8353            working_directory,
8354            terminal_size,
8355            initial_prompt,
8356        };
8357        request
8358            .validate()
8359            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8360        self.ensure_binding_capacity()?;
8361        let resolved_materialization = match self.resolve_record_materialization(
8362            record_id,
8363            &record.provider,
8364            record.mode,
8365            record.environment_profile.as_ref(),
8366            record.bundle.as_ref(),
8367            record.context.as_ref(),
8368        ) {
8369            Ok(materialization) => materialization,
8370            Err(error) => {
8371                let label = match error.code {
8372                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8373                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8374                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8375                    _ => "environment-profile-unavailable",
8376                };
8377                self.mark_record_error(record_id, label)?;
8378                return Err(error);
8379            }
8380        };
8381        let (materialization_id, environment_overlay) = match resolved_materialization {
8382            Some((id, overlay)) => (Some(id), overlay),
8383            None => (None, None),
8384        };
8385        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8386        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8387            instance_id,
8388            &record.provider,
8389            record.mode,
8390            record.environment_profile.as_ref(),
8391        ) {
8392            Ok(selection) => selection,
8393            Err(error) => {
8394                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8395                return Err(error);
8396            }
8397        };
8398        let mut instance_overlay = environment_overlay
8399            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8400            .transpose()?
8401            .flatten();
8402        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8403            self.remove_session(&stale_address).await?;
8404        }
8405        let address = SessionAddress {
8406            workspace_id: record.workspace_id.clone(),
8407            session: SessionKey {
8408                instance_id,
8409                generation: SessionGeneration(1),
8410            },
8411        };
8412        let transaction = self
8413            .state_transaction
8414            .lock()
8415            .unwrap_or_else(|poisoned| poisoned.into_inner());
8416        let current = self.record(record_id)?;
8417        if current.active_session.is_some()
8418            || matches!(
8419                current.state,
8420                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8421            )
8422        {
8423            return Err(failure(
8424                NodeFailureCode::SessionRecordBusy,
8425                "managed session became active while resume was being prepared",
8426            ));
8427        }
8428        self.bind_managed_session_with_materialization(
8429            &address,
8430            record_id.clone(),
8431            runtime_policy,
8432            record.environment_profile.clone(),
8433            record.bundle.clone(),
8434            record.context.clone(),
8435            materialization_id,
8436        );
8437        if let Some(selection) = environment_selection.take() {
8438            selection.retain();
8439        }
8440        if let Some(overlay) = instance_overlay.take() {
8441            overlay.retain();
8442        }
8443        drop(transaction);
8444
8445        let transport = match record.mode {
8446            SessionMode::Pty => TransportKind::Pty,
8447            SessionMode::Inline => TransportKind::Pipe,
8448            SessionMode::Acp => TransportKind::Acp,
8449        };
8450        let agent_id = record.provider.clone();
8451        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8452        if let Err(error) = self
8453            .dispatch_bounded(
8454                ControlCommand::Register {
8455                    instance_id,
8456                    agent_id,
8457                    transport,
8458                },
8459                dispatch_timeout,
8460            )
8461            .await
8462        {
8463            if let Some(binding) = self.remove_binding(&address) {
8464                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8465            }
8466            return Err(error);
8467        }
8468        let command = self.prepare_command(ControlCommand::Resume {
8469            instance_id,
8470            target: ResumeTarget::ProviderSession { identity },
8471            runtime_policy,
8472            request,
8473        });
8474        let started_after = self.current_sequence();
8475        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8476            self.rollback_spawn(&address, dispatch_timeout).await?;
8477            return Err(error);
8478        }
8479        if let Err(error) = self.dispatch_envelope(command) {
8480            self.clear_armed_resume(&address);
8481            self.rollback_spawn(&address, dispatch_timeout).await?;
8482            return Err(error);
8483        }
8484        match self
8485            .wait_for_record_resume(&address, started_after)
8486            .await
8487        {
8488            Ok(settled_address) => {
8489                let managed_lease_id = {
8490                    self.managed_worktrees.lock()
8491                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8492                        .lease_for_workspace(&record.workspace_id)
8493                };
8494                if let Some(lease_id) = managed_lease_id {
8495                    {
8496                        let mut bindings = self.session_bindings.lock()
8497                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8498                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8499                            .ok_or_else(|| failure(
8500                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8501                                "resumed managed session lost its exact binding",
8502                            ))?;
8503                        if binding.generation != settled_address.session.generation {
8504                            return Err(failure(
8505                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8506                                "resumed managed session generation diverged",
8507                            ));
8508                        }
8509                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8510                    }
8511                    let snapshot = self.managed_worktrees.lock()
8512                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8513                        .bind_session(
8514                            &lease_id,
8515                            ManagedWorktreeSessionHolder {
8516                                incarnation_id: self.incarnation_id,
8517                                instance_id: settled_address.session.instance_id,
8518                                generation: settled_address.session.generation,
8519                            },
8520                            Some(record_id.clone()),
8521                            unix_time_ms(),
8522                        ).map_err(|message| failure(
8523                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8524                            &message,
8525                        ))?;
8526                    if let Err(error) = self.persist_state() {
8527                        let _ = self.remove_session(&settled_address).await;
8528                        return Err(persistence_failure(error));
8529                    }
8530                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8531                }
8532                Ok((self.record(record_id)?, settled_address))
8533            }
8534            Err(error) => {
8535                if matches!(
8536                    error.code,
8537                    NodeFailureCode::SessionRecordNotResumable
8538                        | NodeFailureCode::BackendOperationFailed
8539                ) {
8540                    if let Some(bound) = self.bound_address_for_record(record_id) {
8541                        let _ = self.remove_session(&bound).await;
8542                    }
8543                }
8544                Err(error)
8545            }
8546        }
8547    }
8548
8549    async fn wait_for_record_resume(
8550        &self,
8551        initial_address: &SessionAddress,
8552        after_sequence: u64,
8553    ) -> Result<SessionAddress, NodeFailure> {
8554        let deadline = Instant::now()
8555            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8556        let mut scan_after = after_sequence;
8557        loop {
8558            let events = self
8559                .history
8560                .lock()
8561                .unwrap_or_else(|poisoned| poisoned.into_inner())
8562                .events
8563                .iter()
8564                .filter(|event| event.sequence > scan_after)
8565                .cloned()
8566                .collect::<Vec<_>>();
8567            for envelope in events {
8568                scan_after = scan_after.max(envelope.sequence);
8569                let NodeEvent::Control { address, event } = envelope.event else {
8570                    continue;
8571                };
8572                if address.session.instance_id != initial_address.session.instance_id {
8573                    continue;
8574                }
8575                match event.event {
8576                    ControlEventKind::Resumed { .. } => return Ok(address),
8577                    ControlEventKind::ResumeDenied { reason } => {
8578                        return Err(failure(
8579                            NodeFailureCode::SessionRecordNotResumable,
8580                            &reason,
8581                        ));
8582                    }
8583                    ControlEventKind::ResumeFailed { message }
8584                    | ControlEventKind::CommandRejected { message } => {
8585                        return Err(failure(
8586                            NodeFailureCode::BackendOperationFailed,
8587                            &message,
8588                        ));
8589                    }
8590                    _ => {}
8591                }
8592            }
8593            if Instant::now() >= deadline {
8594                return Err(failure(
8595                    NodeFailureCode::BackendBusy,
8596                    "managed resume did not settle before the bounded deadline",
8597                ));
8598            }
8599            sleep(Duration::from_millis(5)).await;
8600        }
8601    }
8602
8603    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8604        self.workspaces
8605            .read()
8606            .unwrap_or_else(|poisoned| poisoned.into_inner())
8607            .get(workspace_id)
8608            .cloned()
8609            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8610    }
8611
8612    fn workspace_roots(&self) -> Vec<PathBuf> {
8613        self.workspaces
8614            .read()
8615            .unwrap_or_else(|poisoned| poisoned.into_inner())
8616            .values()
8617            .map(PathBuf::from)
8618            .collect()
8619    }
8620
8621    async fn inspect_workspace(
8622        &self,
8623        workspace_id: WorkspaceId,
8624    ) -> Result<WorkspaceInspection, NodeFailure> {
8625        let _permit = self
8626            .inspection_slots
8627            .clone()
8628            .try_acquire_owned()
8629            .map_err(|_| {
8630                tracing::warn!(
8631                    workspace_id = %workspace_id,
8632                    "workspace inspection rejected: inspection capacity is busy",
8633                );
8634                failure(
8635                    NodeFailureCode::BackendBusy,
8636                    "workspace inspection capacity is busy",
8637                )
8638            })?;
8639        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8640            tracing::warn!(
8641                workspace_id = %workspace_id,
8642                code = ?error.code,
8643                "workspace inspection rejected: workspace is not registered",
8644            );
8645            error
8646        })?;
8647        let tree_root = canonical_root.clone();
8648        let started_at = Instant::now();
8649        let time_budget_ms = workspace_inspection_time_budget_ms();
8650        let entry_cap = workspace_inspection_entry_cap();
8651        let deadline = started_at + Duration::from_millis(time_budget_ms);
8652        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8653            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8654        })
8655        .await
8656        .map_err(|error| {
8657            tracing::warn!(
8658                workspace_id = %workspace_id,
8659                error = %error,
8660                "workspace inspection rejected: walk task failed",
8661            );
8662            failure(
8663                NodeFailureCode::BackendOperationFailed,
8664                &format!("workspace inspection task failed: {error}"),
8665            )
8666        })?;
8667        let (mut git, git_time_budget_exceeded) =
8668            inspect_git_workspace(&canonical_root, deadline).await;
8669        if !git.worktrees.is_empty() {
8670            let registered = self
8671                .workspaces
8672                .read()
8673                .unwrap_or_else(|poisoned| poisoned.into_inner())
8674                .iter()
8675                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8676                .collect::<Vec<_>>();
8677            for worktree in &mut git.worktrees {
8678                worktree.workspace_id = registered
8679                    .iter()
8680                    .find(|(_, root)| {
8681                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8682                    })
8683                    .map(|(workspace_id, _)| workspace_id.clone());
8684            }
8685        }
8686        if git.is_repository {
8687            git.managed_worktree = self
8688                .managed_worktrees
8689                .lock()
8690                .unwrap_or_else(|poisoned| poisoned.into_inner())
8691                .git_scope(&workspace_id, git.branch.as_deref());
8692        }
8693        let truncation = workspace_inspection_truncation(
8694            &walk_budget,
8695            git_time_budget_exceeded,
8696            started_at.elapsed(),
8697        );
8698        if let Some(truncation) = &truncation {
8699            tracing::info!(
8700                workspace_id = %workspace_id,
8701                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8702                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8703                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8704                entries_visited = truncation.entries_visited,
8705                elapsed_ms = truncation.elapsed_ms,
8706                "workspace inspection truncated by its inner budget",
8707            );
8708        }
8709        Ok(WorkspaceInspection {
8710            workspace_id,
8711            entries,
8712            tree_truncated,
8713            git,
8714            truncation,
8715        })
8716    }
8717
8718    async fn browse_host_directories(
8719        &self,
8720        directory: Option<OpaqueHostPath>,
8721        after: Option<OpaqueHostPath>,
8722    ) -> Result<HostDirectoryListing, NodeFailure> {
8723        let permit = self
8724            .inspection_slots
8725            .clone()
8726            .try_acquire_owned()
8727            .map_err(|_| {
8728                failure(
8729                    NodeFailureCode::BackendBusy,
8730                    "host directory browse capacity is busy",
8731                )
8732            })?;
8733        let task = tokio::task::spawn_blocking(move || {
8734            let _permit = permit;
8735            browse_host_directories(directory, after)
8736        });
8737        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8738            .await
8739            .map_err(|_| {
8740                failure(
8741                    NodeFailureCode::HostDirectoryReadTimedOut,
8742                    "host directory browse exceeded its bounded deadline",
8743                )
8744            })?
8745            .map_err(|_| {
8746                failure(
8747                    NodeFailureCode::HostDirectoryReadFailed,
8748                    "host directory browse task failed",
8749                )
8750            })?
8751            .map_err(host_directory_failure)
8752    }
8753
8754    async fn read_workspace_file(
8755        &self,
8756        workspace_id: WorkspaceId,
8757        path: RepositoryPath,
8758    ) -> Result<WorkspaceFileRead, NodeFailure> {
8759        let canonical_root = self.workspace_root(&workspace_id)?;
8760        let permit = self
8761            .inspection_slots
8762            .clone()
8763            .try_acquire_owned()
8764            .map_err(|_| {
8765                failure(
8766                    NodeFailureCode::BackendBusy,
8767                    "workspace file read capacity is busy",
8768                )
8769            })?;
8770        let path_to_read = path.clone();
8771        let task = tokio::task::spawn_blocking(move || {
8772            let _permit = permit;
8773            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8774        });
8775        let content = timeout(
8776            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8777            task,
8778        )
8779        .await
8780        .map_err(|_| {
8781            failure(
8782                NodeFailureCode::RepositoryFileReadTimedOut,
8783                "repository file read exceeded its bounded deadline",
8784            )
8785        })?
8786        .map_err(|_| {
8787            failure(
8788                NodeFailureCode::RepositoryFileReadFailed,
8789                "repository file read task failed",
8790            )
8791        })?
8792        .map_err(workspace_file_failure)?;
8793        let content = match content {
8794            WorkspaceFileBytes::Utf8(text) => {
8795                let revision = Some(workspace_file_revision(text.as_bytes()));
8796                return Ok(WorkspaceFileRead {
8797                    workspace_id,
8798                    path,
8799                    content: WorkspaceFileContent::Utf8 {
8800                        byte_len: u32::try_from(text.len())
8801                            .expect("bounded workspace text length must fit u32"),
8802                        text,
8803                    },
8804                    revision,
8805                });
8806            }
8807            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8808                WorkspaceFileContent::NonUtf8 {
8809                    byte_len: u32::try_from(byte_length)
8810                        .expect("bounded workspace file length must fit u32"),
8811                }
8812            }
8813            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8814                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8815            },
8816        };
8817        Ok(WorkspaceFileRead {
8818            workspace_id,
8819            path,
8820            content,
8821            revision: None,
8822        })
8823    }
8824
8825    async fn write_workspace_file(
8826        &self,
8827        workspace_id: WorkspaceId,
8828        path: RepositoryPath,
8829        expected_revision: WorkspaceFileRevision,
8830        text: String,
8831    ) -> Result<WorkspaceFileRead, NodeFailure> {
8832        let canonical_root = self.workspace_root(&workspace_id)?;
8833        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8834            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8835        })?;
8836        let path_to_write = path.clone();
8837        let expected = expected_revision.as_str().to_owned();
8838        let text_to_write = text.clone();
8839        let task = tokio::task::spawn_blocking(move || {
8840            let _permit = permit;
8841            write_workspace_file_to_disk(
8842                Path::new(&canonical_root),
8843                &path_to_write,
8844                &expected,
8845                &text_to_write,
8846            )
8847        });
8848        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8849            .await
8850            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8851            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8852            .map_err(workspace_file_write_failure)?;
8853        Ok(WorkspaceFileRead {
8854            workspace_id,
8855            path,
8856            content: WorkspaceFileContent::Utf8 {
8857                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8858                text,
8859            },
8860            revision: Some(WorkspaceFileRevision::new(revision)
8861                .expect("workspace writer returns a valid SHA-256 revision")),
8862        })
8863    }
8864
8865    async fn create_workspace_file(
8866        &self,
8867        workspace_id: WorkspaceId,
8868        path: RepositoryPath,
8869    ) -> Result<WorkspaceFileRead, NodeFailure> {
8870        let canonical_root = self.workspace_root(&workspace_id)?;
8871        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8872            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8873        })?;
8874        let path_to_create = path.clone();
8875        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8876        let task_commit_state = Arc::clone(&commit_state);
8877        let task = tokio::task::spawn_blocking(move || {
8878            let _permit = permit;
8879            create_workspace_file_on_disk(
8880                Path::new(&canonical_root),
8881                &path_to_create,
8882                &task_commit_state,
8883            )
8884        });
8885        let revision = settle_workspace_entry_create(
8886            task,
8887            commit_state,
8888            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8889            "repository file create exceeded its bounded deadline",
8890            "repository file create task failed",
8891        )
8892        .await?;
8893        Ok(WorkspaceFileRead {
8894            workspace_id,
8895            path,
8896            content: WorkspaceFileContent::Utf8 {
8897                text: String::new(),
8898                byte_len: 0,
8899            },
8900            revision: Some(WorkspaceFileRevision::new(revision)
8901                .expect("workspace creator returns a valid SHA-256 revision")),
8902        })
8903    }
8904
8905    async fn create_workspace_directory(
8906        &self,
8907        workspace_id: WorkspaceId,
8908        path: RepositoryPath,
8909    ) -> Result<WorkspaceEntry, NodeFailure> {
8910        let canonical_root = self.workspace_root(&workspace_id)?;
8911        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8912            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8913        })?;
8914        let path_to_create = path.clone();
8915        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8916        let task_commit_state = Arc::clone(&commit_state);
8917        let task = tokio::task::spawn_blocking(move || {
8918            let _permit = permit;
8919            create_workspace_directory_on_disk(
8920                Path::new(&canonical_root),
8921                &path_to_create,
8922                &task_commit_state,
8923            )
8924        });
8925        settle_workspace_entry_create(
8926            task,
8927            commit_state,
8928            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8929            "repository directory create exceeded its bounded deadline",
8930            "repository directory create task failed",
8931        )
8932        .await?;
8933        Ok(WorkspaceEntry {
8934            relative_path: path,
8935            kind: WorkspaceEntryKind::Directory,
8936        })
8937    }
8938
8939    async fn read_git_history(
8940        &self,
8941        workspace_id: WorkspaceId,
8942        path: Option<RepositoryPath>,
8943        before: Option<GitObjectId>,
8944        limit: u16,
8945    ) -> Result<GitHistoryPage, NodeFailure> {
8946        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8947            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8948        })?;
8949        let root = self.workspace_root(&workspace_id)?;
8950        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8951    }
8952
8953    async fn read_git_diff(
8954        &self,
8955        workspace_id: WorkspaceId,
8956        request: GitDiffRequest,
8957    ) -> Result<GitDiff, NodeFailure> {
8958        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8959            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8960        })?;
8961        let root = self.workspace_root(&workspace_id)?;
8962        read_git_diff_bounded(&root, request).await
8963    }
8964
8965    fn native_session_route_root(
8966        &self,
8967        route: &NativeSessionCatalogRoute,
8968    ) -> Result<Option<String>, NodeFailure> {
8969        route
8970            .validate()
8971            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8972        match route.scope {
8973            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8974                .workspace_id
8975                .as_ref()
8976                .map(|workspace_id| self.workspace_root(workspace_id))
8977                .transpose(),
8978            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8979        }
8980    }
8981
8982    fn project_native_session_entries(
8983        &self,
8984        route: &NativeSessionCatalogRoute,
8985        entries: Vec<ScopedNativeSessionCatalogEntry>,
8986    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8987        let current_root = route
8988            .workspace_id
8989            .as_ref()
8990            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8991        let records = self
8992            .session_records
8993            .lock()
8994            .unwrap_or_else(|poisoned| poisoned.into_inner());
8995        let mut seen_records = std::collections::HashSet::new();
8996        entries
8997            .into_iter()
8998            .map(|entry| {
8999                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9000                    let matches = records
9001                        .records
9002                        .values()
9003                        .filter(|record| {
9004                            record.provider == route.provider
9005                                && record.workspace_id == *workspace_id
9006                                && current_root.as_deref().is_some_and(|root| {
9007                                    platform::roots_equal(
9008                                        root,
9009                                        windows_path_text(&record.canonical_root),
9010                                    )
9011                                })
9012                                && record.provider_session.as_ref().is_some_and(|identity| {
9013                                    session_registry::same_provider_session(
9014                                        &record.provider,
9015                                        identity,
9016                                        &route.provider,
9017                                        &entry.provider_session,
9018                                    )
9019                                })
9020                        })
9021                        .map(|record| record.record_id.clone())
9022                        .collect::<Vec<_>>();
9023                    (matches.len() == 1
9024                        && seen_records.insert(matches[0].clone()))
9025                        .then(|| matches[0].clone())
9026                });
9027                let metadata = entry.metadata;
9028                crate::protocol::NativeSessionCatalogEntry {
9029                    selection_id: metadata.selection_id,
9030                    title: metadata.title,
9031                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9032                    model: metadata.model,
9033                    message_count: metadata.message_count,
9034                    completed_turn_count: metadata.completed_turn_count,
9035                    external_group: entry.external_group,
9036                    record_id,
9037                }
9038            })
9039            .collect()
9040    }
9041
9042    async fn catalog_native_sessions(
9043        &self,
9044        route: NativeSessionCatalogRoute,
9045        limit: u16,
9046    ) -> Result<(
9047        Vec<crate::protocol::NativeSessionCatalogEntry>,
9048        crate::protocol::NativeSessionCatalogSummary,
9049    ), NodeFailure> {
9050        let canonical_root = self.native_session_route_root(&route)?;
9051        let registered_workspace_roots = self.workspace_roots();
9052        let provider = route.provider.clone();
9053        let scope = route.scope;
9054        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9055            failure(
9056                NodeFailureCode::InvalidRequest,
9057                "native session catalog is not configured",
9058            )
9059        })?;
9060        let _permit = Arc::clone(&self.inspection_slots)
9061            .try_acquire_owned()
9062            .map_err(|_| {
9063                failure(
9064                    NodeFailureCode::BackendBusy,
9065                    "native session catalog capacity is busy",
9066                )
9067            })?;
9068        let page = run_native_session_catalog_operation(
9069            catalog,
9070            "native session catalog",
9071            move |catalog| {
9072                catalog.catalog_initial_for_scope(
9073                    &provider,
9074                    scope,
9075                    canonical_root.as_deref().map(Path::new),
9076                    &registered_workspace_roots,
9077                    limit,
9078                )
9079            },
9080        )
9081        .await?;
9082        let summary = crate::protocol::NativeSessionCatalogSummary {
9083            catalog_revision: page.revision,
9084            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9085            recent_total_count: page.recent_total_count,
9086            older_total_count: page.older_total_count,
9087            recent_next_after_selection_id: page.next_after_selection_id,
9088            recent_has_more: page.remaining_count > 0,
9089        };
9090        Ok((self.project_native_session_entries(&route, page.entries), summary))
9091    }
9092
9093    async fn page_native_sessions(
9094        &self,
9095        route: NativeSessionCatalogRoute,
9096        window: crate::protocol::NativeSessionCatalogWindow,
9097        catalog_revision: u64,
9098        recent_cutoff_unix_ms: u64,
9099        after_selection_id: Option<String>,
9100        limit: u16,
9101    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9102        let canonical_root = self.native_session_route_root(&route)?;
9103        let registered_workspace_roots = self.workspace_roots();
9104        let provider = route.provider.clone();
9105        let scope = route.scope;
9106        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9107            failure(
9108                NodeFailureCode::InvalidRequest,
9109                "native session catalog is not configured",
9110            )
9111        })?;
9112        let _permit = Arc::clone(&self.inspection_slots)
9113            .try_acquire_owned()
9114            .map_err(|_| {
9115                failure(
9116                    NodeFailureCode::BackendBusy,
9117                    "native session catalog capacity is busy",
9118                )
9119            })?;
9120        let page = run_native_session_catalog_operation(
9121            catalog,
9122            "native session catalog page",
9123            move |catalog| {
9124                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9125                    &provider,
9126                    scope,
9127                    canonical_root.as_deref().map(Path::new),
9128                    &registered_workspace_roots,
9129                    window,
9130                    catalog_revision,
9131                    recent_cutoff_unix_ms,
9132                    after_selection_id.as_deref(),
9133                    limit,
9134                ))
9135            },
9136        )
9137        .await?;
9138        let page = page.map_err(|error| match error {
9139            NativeSessionCatalogError::StaleCatalog => failure(
9140                NodeFailureCode::StaleNativeSessionCatalog,
9141                "native session catalog revision or cursor is stale",
9142            ),
9143            _ => failure(
9144                NodeFailureCode::BackendOperationFailed,
9145                "native session catalog page failed",
9146            ),
9147        })?;
9148        let entries = self.project_native_session_entries(&route, page.entries);
9149        Ok(crate::protocol::NativeSessionCatalogPage {
9150            window: page.window,
9151            revision: page.revision,
9152            entries,
9153            next_after_selection_id: page.next_after_selection_id,
9154            remaining_count: page.remaining_count,
9155            has_more: page.remaining_count > 0,
9156        })
9157    }
9158
9159    async fn preview_native_session(
9160        &self,
9161        selection: NativeSessionSelection,
9162        message_limit: u16,
9163    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9164        selection
9165            .validate()
9166            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9167        let canonical_root = self.native_session_route_root(&selection.route)?;
9168        let registered_workspace_roots = self.workspace_roots();
9169        let provider = selection.route.provider.clone();
9170        let scope = selection.route.scope;
9171        let catalog_revision = selection.catalog_revision;
9172        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9173        let selection_id = selection.selection_id.clone();
9174        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9175            failure(
9176                NodeFailureCode::InvalidRequest,
9177                "native session preview is not configured",
9178            )
9179        })?;
9180        let _permit = Arc::clone(&self.inspection_slots)
9181            .try_acquire_owned()
9182            .map_err(|_| {
9183                failure(
9184                    NodeFailureCode::BackendBusy,
9185                    "native session preview capacity is busy",
9186                )
9187            })?;
9188        let preview = run_native_session_catalog_operation(
9189            catalog,
9190            "native session preview",
9191            move |catalog| {
9192                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9193                    &provider,
9194                    scope,
9195                    canonical_root.as_deref().map(Path::new),
9196                    &registered_workspace_roots,
9197                    catalog_revision,
9198                    recent_cutoff_unix_ms,
9199                    &selection_id,
9200                    message_limit,
9201                ))
9202            },
9203        )
9204        .await?;
9205        preview
9206            .map(Into::into)
9207            .map_err(native_session_preview_failure)
9208    }
9209
9210    async fn index_native_session(
9211        &self,
9212        selection: NativeSessionSelection,
9213        display_name: String,
9214    ) -> Result<ManagedSessionRecord, NodeFailure> {
9215        selection
9216            .validate()
9217            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9218        if selection.route.scope
9219            != gate4agent_types::NativeSessionCatalogScope::Workspace
9220        {
9221            return Err(failure(
9222                NodeFailureCode::WorkspaceRegistrationRequired,
9223                "register the external project as a workspace before indexing or resuming it",
9224            ));
9225        }
9226        let workspace_id = selection
9227            .route
9228            .workspace_id
9229            .clone()
9230            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9231        let canonical_root = self
9232            .native_session_route_root(&selection.route)?
9233            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9234        let registered_workspace_roots = self.workspace_roots();
9235        let provider = selection.route.provider.clone();
9236        let catalog_revision = selection.catalog_revision;
9237        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9238        let selection_id = selection.selection_id.clone();
9239        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9240            failure(
9241                NodeFailureCode::InvalidRequest,
9242                "native session index is not configured",
9243            )
9244        })?;
9245        let _permit = Arc::clone(&self.inspection_slots)
9246            .try_acquire_owned()
9247            .map_err(|_| {
9248                failure(
9249                    NodeFailureCode::BackendBusy,
9250                    "native session index capacity is busy",
9251                )
9252            })?;
9253        let resolved = run_native_session_catalog_operation(
9254            catalog,
9255            "native session index resolution",
9256            move |catalog| {
9257                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9258                    &provider,
9259                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9260                    Some(Path::new(&canonical_root)),
9261                    &registered_workspace_roots,
9262                    catalog_revision,
9263                    recent_cutoff_unix_ms,
9264                    &selection_id,
9265                ))
9266            },
9267        )
9268        .await?
9269        .map_err(native_session_preview_failure)?;
9270        self.index_provider_session_with_policy(
9271            workspace_id,
9272            selection.route.provider,
9273            resolved.identity,
9274            display_name,
9275            true,
9276        )
9277    }
9278
9279    async fn preview_native_session_by_session_id(
9280        &self,
9281        workspace_id: WorkspaceId,
9282        provider: AgentId,
9283        session_id: String,
9284        message_limit: u16,
9285    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9286        let canonical_root = self.workspace_root(&workspace_id)?;
9287        let registered_workspace_roots = self.workspace_roots();
9288        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9289            failure(
9290                NodeFailureCode::InvalidRequest,
9291                "native session preview is not configured",
9292            )
9293        })?;
9294        let _permit = Arc::clone(&self.inspection_slots)
9295            .try_acquire_owned()
9296            .map_err(|_| {
9297                failure(
9298                    NodeFailureCode::BackendBusy,
9299                    "native session preview capacity is busy",
9300                )
9301            })?;
9302        run_native_session_catalog_operation(
9303            catalog,
9304            "native session preview",
9305            move |catalog| {
9306                catalog.preview_session_id_for_workspace(
9307                    &provider,
9308                    Path::new(&canonical_root),
9309                    &registered_workspace_roots,
9310                    &session_id,
9311                    message_limit,
9312                )
9313            },
9314        )
9315        .await
9316    }
9317
9318    async fn register_workspace(
9319        &self,
9320        workspace_id: WorkspaceId,
9321        root: String,
9322    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9323        validate_workspace_request_root(&root)?;
9324        let canonicalize_workspace_id = workspace_id.clone();
9325        let workspace = tokio::task::spawn_blocking(move || {
9326            WorkspaceConfig::new(canonicalize_workspace_id, root)
9327        })
9328        .await
9329        .map_err(|error| {
9330            failure(
9331                NodeFailureCode::BackendOperationFailed,
9332                &format!("workspace canonicalization task failed: {error}"),
9333            )
9334        })?
9335        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9336        let transaction = self
9337            .state_transaction
9338            .lock()
9339            .unwrap_or_else(|poisoned| poisoned.into_inner());
9340        let mut workspaces = self
9341            .workspaces
9342            .write()
9343            .unwrap_or_else(|poisoned| poisoned.into_inner());
9344        if workspaces.contains_key(&workspace_id) {
9345            return Err(failure(
9346                NodeFailureCode::DuplicateWorkspaceId,
9347                "workspace ID is already registered",
9348            ));
9349        }
9350        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9351            platform::roots_equal(existing_root, workspace.canonical_root())
9352        }) {
9353            return Err(failure(
9354                NodeFailureCode::DuplicateWorkspaceRoot,
9355                &format!("workspace root is already registered as '{existing_id}'"),
9356            ));
9357        }
9358        {
9359            let records = self
9360                .session_records
9361                .lock()
9362                .unwrap_or_else(|poisoned| poisoned.into_inner());
9363            if let Some(record) = records.records.values().find(|record| {
9364                record.workspace_id == workspace_id
9365                    && !record
9366                        .canonical_root
9367                        .as_utf8()
9368                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9369            }) {
9370                return Err(failure(
9371                    NodeFailureCode::SessionRecordConflict,
9372                    &format!(
9373                        "workspace ID is retained by session record '{}' at another root",
9374                        record.record_id,
9375                    ),
9376                ));
9377            }
9378        }
9379        let snapshot = WorkspaceSnapshot {
9380            workspace_id: workspace_id.clone(),
9381            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9382            sessions: Vec::new(),
9383            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9384            managed_worktree_profiles: Some(WorktreeProfileInventory {
9385                profiles: Vec::new(),
9386            }),
9387        };
9388        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9389        drop(workspaces);
9390        let previous_records = {
9391            let mut records = self
9392                .session_records
9393                .lock()
9394                .unwrap_or_else(|poisoned| poisoned.into_inner());
9395            let previous = records.records.clone();
9396            for record in records.records.values_mut().filter(|record| {
9397                record.workspace_id == workspace_id
9398                    && record
9399                        .canonical_root
9400                        .as_utf8()
9401                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9402            }) {
9403                if record.active_session.is_none() {
9404                    record.state = detached_record_state(record);
9405                }
9406                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9407                    record.last_error = None;
9408                }
9409                record.updated_at_unix_ms = unix_time_ms();
9410            }
9411            previous
9412        };
9413        if let Err(error) = self.persist_state_locked() {
9414            self.workspaces
9415                .write()
9416                .unwrap_or_else(|poisoned| poisoned.into_inner())
9417                .remove(&workspace_id);
9418            self.session_records
9419                .lock()
9420                .unwrap_or_else(|poisoned| poisoned.into_inner())
9421                .records = previous_records;
9422            return Err(persistence_failure(error));
9423        }
9424        let restored = self
9425            .session_records
9426            .lock()
9427            .unwrap_or_else(|poisoned| poisoned.into_inner())
9428            .records
9429            .values()
9430            .filter(|record| record.workspace_id == workspace_id)
9431            .cloned()
9432            .collect::<Vec<_>>();
9433        drop(transaction);
9434        self.publish(NodeEvent::WorkspaceAdded {
9435            workspace: snapshot.clone(),
9436        });
9437        for record in restored {
9438            self.publish_record(record);
9439        }
9440        Ok(snapshot)
9441    }
9442
9443    async fn create_standalone_workspace(
9444        &self,
9445        workspace_id: WorkspaceId,
9446        root: String,
9447        initial_branch: Option<String>,
9448    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9449        validate_workspace_request_root(&root)?;
9450        validate_git_revision("initial branch", initial_branch.as_deref())?;
9451        let preflight_workspace_id = workspace_id.clone();
9452        let preflight_root = root.clone();
9453        let canonical_candidate = tokio::task::spawn_blocking(move || {
9454            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9455        })
9456        .await
9457        .map_err(|_| failure(
9458            NodeFailureCode::BackendOperationFailed,
9459            "standalone workspace root preflight task failed",
9460        ))?
9461        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9462        {
9463            let workspaces = self
9464                .workspaces
9465                .read()
9466                .unwrap_or_else(|poisoned| poisoned.into_inner());
9467            if workspaces.contains_key(&workspace_id) {
9468                return Err(failure(
9469                    NodeFailureCode::DuplicateWorkspaceId,
9470                    "workspace ID is already registered",
9471                ));
9472            }
9473            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9474                platform::roots_equal(existing_root, &canonical_candidate)
9475            }) {
9476                return Err(failure(
9477                    NodeFailureCode::DuplicateWorkspaceRoot,
9478                    &format!("workspace root is already registered as '{existing_id}'"),
9479                ));
9480            }
9481        }
9482        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9483            .await
9484            .map_err(standalone_workspace_failure)?;
9485        prepared
9486            .verify_for_registration()
9487            .await
9488            .map_err(standalone_workspace_failure)?;
9489        match self
9490            .register_workspace(workspace_id, prepared.root().to_owned())
9491            .await
9492        {
9493            Ok(workspace) => Ok(workspace),
9494            Err(registration_error) => {
9495                if prepared.compensate_registration_failure().await {
9496                    Err(registration_error)
9497                } else {
9498                    Err(failure(
9499                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9500                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9501                    ))
9502                }
9503            }
9504        }
9505    }
9506
9507    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9508        let bindings = self
9509            .session_bindings
9510            .lock()
9511            .unwrap_or_else(|poisoned| poisoned.into_inner());
9512        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9513            return Err(failure(
9514                NodeFailureCode::WorkspaceBusy,
9515                "workspace retains a bound session; remove every session before unregistering it",
9516            ));
9517        }
9518        drop(bindings);
9519        let transaction = self
9520            .state_transaction
9521            .lock()
9522            .unwrap_or_else(|poisoned| poisoned.into_inner());
9523        let mut workspaces = self
9524            .workspaces
9525            .write()
9526            .unwrap_or_else(|poisoned| poisoned.into_inner());
9527        if !workspaces.contains_key(workspace_id) {
9528            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9529        }
9530        if workspaces.len() == 1 {
9531            return Err(failure(
9532                NodeFailureCode::LastWorkspace,
9533                "node must retain at least one workspace",
9534            ));
9535        }
9536        let removed_root = workspaces
9537            .remove(workspace_id)
9538            .expect("workspace presence was validated");
9539        drop(workspaces);
9540        let previous_records = {
9541            let mut records = self
9542                .session_records
9543                .lock()
9544                .unwrap_or_else(|poisoned| poisoned.into_inner());
9545            let previous = records.records.clone();
9546            for record in records
9547                .records
9548                .values_mut()
9549                .filter(|record| &record.workspace_id == workspace_id)
9550            {
9551                record.active_session = None;
9552                record.state = ManagedSessionState::Unavailable;
9553                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9554                record.updated_at_unix_ms = unix_time_ms();
9555            }
9556            previous
9557        };
9558        if let Err(error) = self.persist_state_locked() {
9559            self.workspaces
9560                .write()
9561                .unwrap_or_else(|poisoned| poisoned.into_inner())
9562                .insert(workspace_id.clone(), removed_root);
9563            self.session_records
9564                .lock()
9565                .unwrap_or_else(|poisoned| poisoned.into_inner())
9566                .records = previous_records;
9567            return Err(persistence_failure(error));
9568        }
9569        let unavailable = self
9570            .session_records
9571            .lock()
9572            .unwrap_or_else(|poisoned| poisoned.into_inner())
9573            .records
9574            .values()
9575            .filter(|record| &record.workspace_id == workspace_id)
9576            .cloned()
9577            .collect::<Vec<_>>();
9578        drop(transaction);
9579        self.publish(NodeEvent::WorkspaceRemoved {
9580            workspace_id: workspace_id.clone(),
9581        });
9582        for record in unavailable {
9583            self.publish_record(record);
9584        }
9585        Ok(())
9586    }
9587
9588    async fn create_worktree(
9589        &self,
9590        source_workspace_id: WorkspaceId,
9591        workspace_id: WorkspaceId,
9592        target_root: String,
9593        branch: String,
9594        base: Option<String>,
9595    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9596        self.require_worktree_service(&source_workspace_id)?;
9597        validate_workspace_request_root(&target_root)?;
9598        validate_git_revision("worktree base", base.as_deref())?;
9599        let source_root = self.workspace_root(&source_workspace_id)?;
9600        {
9601            let workspaces = self
9602                .workspaces
9603                .read()
9604                .unwrap_or_else(|poisoned| poisoned.into_inner());
9605            if workspaces.contains_key(&workspace_id) {
9606                return Err(failure(
9607                    NodeFailureCode::DuplicateWorkspaceId,
9608                    "workspace ID is already registered",
9609                ));
9610            }
9611            if let Some((existing_id, _)) = workspaces
9612                .iter()
9613                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9614            {
9615                return Err(failure(
9616                    NodeFailureCode::DuplicateWorkspaceRoot,
9617                    &format!("workspace root is already registered as '{existing_id}'"),
9618                ));
9619            }
9620        }
9621        let mut worktree = create_git_worktree(
9622            &source_root,
9623            &target_root,
9624            &branch,
9625            base.as_deref(),
9626        )
9627        .await
9628        .map_err(git_worktree_failure)?;
9629        let workspace = match self
9630            .register_workspace(workspace_id, worktree.path.clone())
9631            .await
9632        {
9633            Ok(workspace) => workspace,
9634            Err(registration_error) => {
9635                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9636                    return Err(failure(
9637                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9638                        "worktree registration failed and compensation requires recovery",
9639                    ));
9640                }
9641                return Err(failure(
9642                    registration_error.code,
9643                    &registration_error.message,
9644                ));
9645            }
9646        };
9647        worktree.workspace_id = Some(workspace.workspace_id.clone());
9648        Ok((protocol_worktree(worktree), workspace))
9649    }
9650
9651    async fn remove_worktree(
9652        &self,
9653        source_workspace_id: WorkspaceId,
9654        target_root: String,
9655    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9656        self.require_worktree_service(&source_workspace_id)?;
9657        validate_workspace_request_root(&target_root)?;
9658        let source_root = self.workspace_root(&source_workspace_id)?;
9659        let target_root = normalize_worktree_removal_target(&target_root)
9660            .map_err(git_worktree_failure)?;
9661        let target = list_git_worktrees(&source_root)
9662            .await
9663            .map_err(git_worktree_failure)?
9664            .into_iter()
9665            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9666            .ok_or_else(|| {
9667                failure(
9668                    NodeFailureCode::WorktreeProtected,
9669                    "refusing to remove a path that is not in Git's worktree listing",
9670                )
9671            })?;
9672        let registered_workspace_id = {
9673            let workspaces = self
9674                .workspaces
9675                .read()
9676                .unwrap_or_else(|poisoned| poisoned.into_inner());
9677            let matched = workspaces
9678                .iter()
9679                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9680                .map(|(workspace_id, _)| workspace_id.clone());
9681            if matched.is_some() && workspaces.len() == 1 {
9682                return Err(failure(
9683                    NodeFailureCode::LastWorkspace,
9684                    "node must retain at least one workspace",
9685                ));
9686            }
9687            matched
9688        };
9689        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9690            let bindings = self
9691                .session_bindings
9692                .lock()
9693                .unwrap_or_else(|poisoned| poisoned.into_inner());
9694            if bindings
9695                .values()
9696                .any(|binding| &binding.workspace_id == workspace_id)
9697            {
9698                return Err(failure(
9699                    NodeFailureCode::WorkspaceBusy,
9700                    "worktree workspace retains a bound session; remove every session first",
9701                ));
9702            }
9703        }
9704        remove_git_worktree(&source_root, &target.path)
9705            .await
9706            .map_err(git_worktree_failure)?;
9707        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9708            self.unregister_workspace(workspace_id)
9709                .map_err(|error| failure(error.code, &error.message))?;
9710        }
9711        Ok(registered_workspace_id)
9712    }
9713
9714    async fn cleanup_managed_worktree(
9715        &self,
9716        lease_id: &ManagedWorktreeLeaseId,
9717        explicit: bool,
9718    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9719        let lease = self.managed_worktrees.lock()
9720            .unwrap_or_else(|poisoned| poisoned.into_inner())
9721            .get(lease_id)
9722            .cloned()
9723            .ok_or_else(|| failure(
9724                NodeFailureCode::UnknownManagedWorktreeLease,
9725                "managed worktree lease does not exist",
9726            ))?;
9727        if lease.state == ManagedWorktreeLeaseState::Removed {
9728            return Ok(lease.snapshot());
9729        }
9730        if lease.has_holders() {
9731            return Err(failure(
9732                NodeFailureCode::ManagedWorktreeBusy,
9733                "managed worktree lease still has a session or durable-record holder",
9734            ));
9735        }
9736        if self
9737            .materializations
9738            .lock()
9739            .unwrap_or_else(|poisoned| poisoned.into_inner())
9740            .values()
9741            .any(|record| record.managed_lease_id() == Some(lease_id))
9742        {
9743            return Err(failure(
9744                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9745                "managed worktree cleanup is blocked by session environment ownership",
9746            ));
9747        }
9748        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9749            let snapshot = {
9750                let mut registry = self.managed_worktrees.lock()
9751                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9752                let current = registry.get_mut(lease_id)
9753                    .expect("managed worktree lease remains present");
9754                current.state = ManagedWorktreeLeaseState::Retained;
9755                current.cleanup_failure = None;
9756                current.updated_at_unix_ms = unix_time_ms();
9757                current.snapshot()
9758            };
9759            if self.persist_state().is_err() {
9760                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9761            }
9762            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9763            return Ok(snapshot);
9764        }
9765        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9766        if profile.revision() != &lease.profile_revision {
9767            self.mark_managed_recovery_required(
9768                lease_id,
9769                ManagedWorktreeCleanupFailure::OwnershipConflict,
9770            );
9771            return Err(failure(
9772                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9773                "managed worktree profile revision changed",
9774            ));
9775        }
9776        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9777            self.mark_managed_recovery_required(
9778                lease_id,
9779                ManagedWorktreeCleanupFailure::OwnershipConflict,
9780            );
9781            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9782        })?;
9783        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9784        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9785            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9786            managed_git_worktree_failure(error)
9787        })?;
9788        let target = listed.iter()
9789            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9790        if let Some(target) = target {
9791            if target.is_main || target.is_bare || !exact_owned_worktree(
9792                &lease,
9793                &target.path,
9794                target.branch.as_deref(),
9795                &target.head,
9796            ) {
9797                self.mark_managed_recovery_required(
9798                    lease_id,
9799                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9800                );
9801                return Err(failure(
9802                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9803                    "Git worktree identity no longer matches the managed lease",
9804                ));
9805            }
9806            if target.prunable {
9807                self.mark_managed_cleanup_blocked(
9808                    lease_id,
9809                    ManagedWorktreeCleanupFailure::Prunable,
9810                );
9811                return Err(failure(
9812                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9813                    "managed worktree is prunable and requires recovery",
9814                ));
9815            }
9816            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9817                let failure_kind = match error.kind {
9818                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9819                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9820                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9821                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9822                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9823                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9824                };
9825                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9826                    self.mark_managed_recovery_required(lease_id, failure_kind);
9827                } else {
9828                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9829                }
9830                return Err(managed_git_worktree_failure(error));
9831            }
9832        } else {
9833            match std::fs::symlink_metadata(&lease.target_root) {
9834                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9835                Ok(_) => {
9836                    self.mark_managed_recovery_required(
9837                        lease_id,
9838                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9839                    );
9840                    return Err(failure(
9841                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9842                        "managed target exists but is not owned by Git",
9843                    ));
9844                }
9845                Err(_) => {
9846                    self.mark_managed_cleanup_blocked(
9847                        lease_id,
9848                        ManagedWorktreeCleanupFailure::Backend,
9849                    );
9850                    return Err(failure(
9851                        NodeFailureCode::BackendOperationFailed,
9852                        "managed target identity could not be inspected",
9853                    ));
9854                }
9855            }
9856        }
9857        let registered = self.workspaces.read()
9858            .unwrap_or_else(|poisoned| poisoned.into_inner())
9859            .contains_key(&lease.workspace_id);
9860        if registered {
9861            self.unregister_workspace(&lease.workspace_id)?;
9862        }
9863        let removed = {
9864            self.managed_worktrees.lock()
9865                .unwrap_or_else(|poisoned| poisoned.into_inner())
9866                .tombstone(lease_id, unix_time_ms())
9867                .expect("managed worktree lease remains present during cleanup")
9868        };
9869        self.persist_state().map_err(persistence_failure)?;
9870        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9871        Ok(removed.snapshot())
9872    }
9873
9874    fn mark_managed_cleanup_blocked(
9875        &self,
9876        lease_id: &ManagedWorktreeLeaseId,
9877        failure_kind: ManagedWorktreeCleanupFailure,
9878    ) {
9879        let snapshot = {
9880            let mut registry = self.managed_worktrees.lock()
9881                .unwrap_or_else(|poisoned| poisoned.into_inner());
9882            let Some(lease) = registry.get_mut(lease_id) else { return };
9883            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9884            lease.cleanup_failure = Some(failure_kind);
9885            lease.updated_at_unix_ms = unix_time_ms();
9886            lease.snapshot()
9887        };
9888        let _ = self.persist_state();
9889        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9890    }
9891
9892    fn require_worktree_service(
9893        &self,
9894        source_workspace_id: &WorkspaceId,
9895    ) -> Result<(), NodeFailure> {
9896        if self.managed_worktrees.lock()
9897            .unwrap_or_else(|poisoned| poisoned.into_inner())
9898            .lease_for_workspace(source_workspace_id)
9899            .is_some()
9900        {
9901            return Err(failure(
9902                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9903                "managed worktree targets are internal-only workspaces",
9904            ));
9905        }
9906        match self
9907            .worktree_service_modes
9908            .get(source_workspace_id)
9909            .copied()
9910            .unwrap_or(WorktreeServiceMode::Manual)
9911        {
9912            WorktreeServiceMode::Manual => Ok(()),
9913            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9914                NodeFailureCode::UnsupportedSpawnCapability,
9915                "Git worktree service mode is unavailable in this node build",
9916            )),
9917        }
9918    }
9919
9920    fn managed_reservation_conflict(
9921        &self,
9922        workspace_id: Option<&WorkspaceId>,
9923        root: Option<&str>,
9924    ) -> bool {
9925        self.managed_worktrees.lock()
9926            .unwrap_or_else(|poisoned| poisoned.into_inner())
9927            .active_records()
9928            .any(|lease| {
9929                workspace_id.is_some_and(|workspace_id| {
9930                    workspace_id == &lease.source_workspace_id
9931                        || workspace_id == &lease.workspace_id
9932                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9933            })
9934    }
9935
9936    fn reject_managed_reservation(
9937        &self,
9938        workspace_id: Option<&WorkspaceId>,
9939        root: Option<&str>,
9940    ) -> Result<(), NodeFailure> {
9941        if self.managed_reservation_conflict(workspace_id, root) {
9942            Err(failure(
9943                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9944                "managed worktree ownership may only be mutated through its lease cleanup",
9945            ))
9946        } else {
9947            Ok(())
9948        }
9949    }
9950
9951    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9952        self.validate_address(address)?;
9953        self.workspace_root(&address.workspace_id)
9954    }
9955
9956    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9957    /// `require_policy`'s own doc on those two requirements), so they keep
9958    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9959    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9960    /// `false` on the `require_policy` call below for them because a PTY
9961    /// resume never needs those facts, not because this method cannot look
9962    /// them up.
9963    ///
9964    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9965    /// the SAME `binding.runtime_policy` every other requirement reads here
9966    /// (one computed policy, read by every gate). That policy is no longer
9967    /// PTY-terminal-verification-shaped for an ACP session --
9968    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9969    /// `provider_runtime::policy_for_transport` -- so consulting it for
9970    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9971    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9972    /// even though a prompt is an ACP session's only interface). Pipe keeps
9973    /// reading the same PTY-probe-derived value it always has --
9974    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9975    /// its own doc for why).
9976    ///
9977    /// What this method still adds on top, for ACP/Pipe only, is a
9978    /// turn-in-flight PRECONDITION ahead of that policy check: a session
9979    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
9980    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
9981    /// read off the same live `SessionSnapshot` state `require_acp_session`
9982    /// already reads for the ACP control verbs -- `TurnStarted` observed
9983    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
9984    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
9985    /// its fate in vendor-specific behaviour this node cannot observe (one
9986    /// agent may queue it, another drop it, another interleave it), so this
9987    /// refuses it outright rather than gambling on that -- a rule with no
9988    /// PTY analogue, which is why PTY does not take this extra precondition.
9989    fn require_session_runtime_policy(
9990        &self,
9991        address: &SessionAddress,
9992        requirement: ProviderRuntimeRequirement,
9993    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9994        let runtime_policy = {
9995            let bindings = self
9996                .session_bindings
9997                .lock()
9998                .unwrap_or_else(|poisoned| poisoned.into_inner());
9999            let binding = bindings
10000                .get(&address.session.instance_id)
10001                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10002            if binding.workspace_id != address.workspace_id {
10003                return Err(failure(
10004                    NodeFailureCode::SessionWorkspaceMismatch,
10005                    "session belongs to another workspace",
10006                ));
10007            }
10008            if binding.generation != address.session.generation {
10009                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10010            }
10011            binding.runtime_policy
10012        };
10013        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10014            let snapshot = self.internal_session_snapshot(address)?;
10015            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10016                && snapshot.provider.lead_activity != ProviderActivity::Idle
10017            {
10018                return Err(failure(
10019                    NodeFailureCode::TurnInFlight,
10020                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10021                ));
10022            }
10023        }
10024        require_policy(runtime_policy, false, false, requirement)
10025            .map(|()| runtime_policy)
10026            .map_err(|_| failure(
10027                NodeFailureCode::UnsupportedCapability,
10028                "session runtime policy does not admit this semantic operation",
10029            ))
10030    }
10031
10032    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10033        let mut bindings = self
10034            .session_bindings
10035            .lock()
10036            .unwrap_or_else(|poisoned| poisoned.into_inner());
10037        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10038            binding.workspace_id == address.workspace_id
10039                && binding.generation == address.session.generation
10040        }) {
10041            let removed = bindings.remove(&address.session.instance_id);
10042            self.clear_terminal_frame_watermark(address);
10043            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10044            return removed;
10045        }
10046        None
10047    }
10048
10049    fn cleanup_session_owned_materialization(
10050        &self,
10051        address: &SessionAddress,
10052        binding: &SessionBinding,
10053    ) -> Result<(), NodeFailure> {
10054        let Some(id) = binding.materialization_id.as_ref() else {
10055            return Ok(());
10056        };
10057        let owner = self
10058            .materializations
10059            .lock()
10060            .unwrap_or_else(|poisoned| poisoned.into_inner())
10061            .get(id)
10062            .map(|record| record.owner().clone());
10063        match owner {
10064            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10065            Some(MaterializationOwner::Session {
10066                incarnation_id,
10067                instance_id,
10068                generation,
10069            }) if incarnation_id == self.incarnation_id
10070                && instance_id == address.session.instance_id
10071                && generation == address.session.generation =>
10072            {
10073                self.cleanup_materialization(id)
10074            }
10075            Some(MaterializationOwner::Session { .. }) => Err(failure(
10076                NodeFailureCode::BackendOperationFailed,
10077                "session environment ownership requires recovery",
10078            )),
10079        }
10080    }
10081
10082    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10083        let mut watermarks = self
10084            .terminal_frame_watermarks
10085            .lock()
10086            .unwrap_or_else(|poisoned| poisoned.into_inner());
10087        if watermarks
10088            .get(&address.session.instance_id)
10089            .is_some_and(|(current, _)| current == address)
10090        {
10091            watermarks.remove(&address.session.instance_id);
10092        }
10093    }
10094
10095    #[cfg(test)]
10096    fn address_for(
10097        &self,
10098        instance_id: AgentInstanceId,
10099        generation: SessionGeneration,
10100    ) -> Option<SessionAddress> {
10101        let binding = self.session_bindings
10102            .lock()
10103            .unwrap_or_else(|poisoned| poisoned.into_inner())
10104            .get(&instance_id)
10105            .cloned()?;
10106        (binding.generation == generation).then_some(SessionAddress {
10107            workspace_id: binding.workspace_id,
10108            session: SessionKey { instance_id, generation },
10109        })
10110    }
10111
10112    fn arm_resume(
10113        &self,
10114        address: &SessionAddress,
10115        command_id: CommandId,
10116        runtime_policy: ProviderRuntimePolicy,
10117    ) -> Result<(), NodeFailure> {
10118        let mut bindings = self
10119            .session_bindings
10120            .lock()
10121            .unwrap_or_else(|poisoned| poisoned.into_inner());
10122        let binding = bindings
10123            .get_mut(&address.session.instance_id)
10124            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10125        if binding.workspace_id != address.workspace_id {
10126            return Err(failure(
10127                NodeFailureCode::SessionWorkspaceMismatch,
10128                "session belongs to another workspace",
10129            ));
10130        }
10131        if binding.generation != address.session.generation {
10132            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10133        }
10134        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10135        Ok(())
10136    }
10137
10138    fn clear_armed_resume(&self, address: &SessionAddress) {
10139        let mut bindings = self
10140            .session_bindings
10141            .lock()
10142            .unwrap_or_else(|poisoned| poisoned.into_inner());
10143        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10144            if binding.workspace_id == address.workspace_id
10145                && binding.generation == address.session.generation
10146            {
10147                binding.pending_resume = None;
10148            }
10149        }
10150    }
10151
10152    fn publish_control(&self, event: ControlEvent) {
10153        let revokes_harness_mcp = matches!(
10154            &event.event,
10155            ControlEventKind::Exited { .. }
10156                | ControlEventKind::Failed { .. }
10157                | ControlEventKind::Removed
10158        );
10159        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10160            let mut bindings = self
10161                .session_bindings
10162                .lock()
10163                .unwrap_or_else(|poisoned| poisoned.into_inner());
10164            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10165                return;
10166            };
10167            let previous_address = SessionAddress {
10168                workspace_id: binding.workspace_id.clone(),
10169                session: SessionKey {
10170                    instance_id: event.instance_id,
10171                    generation: binding.generation,
10172                },
10173            };
10174            let mut resume_command_rejected = false;
10175            if binding.generation == event.generation {
10176                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10177                    let rejected_resume = matches!(
10178                        &event.event,
10179                        ControlEventKind::CommandRejected { .. }
10180                    ) && event.command_id == Some(pending_command_id);
10181                    let resume_failed = matches!(
10182                        &event.event,
10183                        ControlEventKind::ResumeDenied { .. }
10184                            | ControlEventKind::ResumeFailed { .. }
10185                    );
10186                    if pending_generation == binding.generation
10187                        && (rejected_resume || resume_failed)
10188                    {
10189                        resume_command_rejected = rejected_resume;
10190                        binding.pending_resume = None;
10191                    }
10192                }
10193            } else {
10194                let expected = binding.generation.0.checked_add(1);
10195                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10196                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10197                    generation == binding.generation
10198                })
10199                    || expected != Some(event.generation.0)
10200                    || !authorized
10201                {
10202                    return;
10203                }
10204                let (_, _, runtime_policy) = binding
10205                    .pending_resume
10206                    .expect("authorized resume retains its pending runtime policy");
10207                binding.generation = event.generation;
10208                binding.runtime_policy = runtime_policy;
10209                binding.pending_resume = None;
10210            }
10211            (
10212                SessionAddress {
10213                    workspace_id: binding.workspace_id.clone(),
10214                    session: SessionKey {
10215                        instance_id: event.instance_id,
10216                        generation: event.generation,
10217                    },
10218                },
10219                (previous_address.session.generation != event.generation)
10220                    .then_some(previous_address),
10221                binding.record_id.clone(),
10222                binding.runtime_policy.provider_session_identity,
10223                resume_command_rejected,
10224            )
10225        };
10226        if let Some(previous_address) = previous_address.as_ref() {
10227            self.clear_terminal_frame_watermark(previous_address);
10228        }
10229        if let Some(record_id) = record_id {
10230            self.reconcile_managed_record(
10231                &record_id,
10232                &address,
10233                &event,
10234                identity_admitted,
10235                resume_command_rejected,
10236            );
10237        }
10238        // Every state change a session's process lifecycle can reach gets a
10239        // line here: this is the single chokepoint every `ControlEventKind`
10240        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10241        // came from a fresh spawn or a resume. Per-message provider chatter
10242        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10243        // matched here -- that is exactly the per-frame noise this crate
10244        // family's level discipline forbids at INFO.
10245        match &event.event {
10246            ControlEventKind::Running { process_id } => {
10247                tracing::info!(
10248                    node_id = %self.node_id,
10249                    workspace_id = %address.workspace_id,
10250                    session = ?address.session,
10251                    pid = ?process_id,
10252                    "session process is running",
10253                );
10254            }
10255            ControlEventKind::Exited { exit_code, forced } => {
10256                tracing::info!(
10257                    node_id = %self.node_id,
10258                    workspace_id = %address.workspace_id,
10259                    session = ?address.session,
10260                    exit_code = ?exit_code,
10261                    forced = forced,
10262                    "session process exited",
10263                );
10264            }
10265            ControlEventKind::Failed { message } => {
10266                tracing::warn!(
10267                    node_id = %self.node_id,
10268                    workspace_id = %address.workspace_id,
10269                    session = ?address.session,
10270                    cause = %message,
10271                    "session failed",
10272                );
10273            }
10274            ControlEventKind::CommandRejected { message } => {
10275                tracing::warn!(
10276                    node_id = %self.node_id,
10277                    workspace_id = %address.workspace_id,
10278                    session = ?address.session,
10279                    cause = %message,
10280                    "session command rejected",
10281                );
10282            }
10283            ControlEventKind::Removed => {
10284                tracing::info!(
10285                    node_id = %self.node_id,
10286                    workspace_id = %address.workspace_id,
10287                    session = ?address.session,
10288                    "session removed",
10289                );
10290            }
10291            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10292                tracing::info!(
10293                    node_id = %self.node_id,
10294                    workspace_id = %address.workspace_id,
10295                    session = ?address.session,
10296                    provider_session = ?provider_session,
10297                    "session resume authorized",
10298                );
10299            }
10300            ControlEventKind::Resumed { session: provider_session, process_id } => {
10301                tracing::info!(
10302                    node_id = %self.node_id,
10303                    workspace_id = %address.workspace_id,
10304                    session = ?address.session,
10305                    provider_session = ?provider_session,
10306                    pid = ?process_id,
10307                    "session resumed",
10308                );
10309            }
10310            ControlEventKind::ResumeDenied { reason } => {
10311                tracing::warn!(
10312                    node_id = %self.node_id,
10313                    workspace_id = %address.workspace_id,
10314                    session = ?address.session,
10315                    cause = %reason,
10316                    "session resume denied",
10317                );
10318            }
10319            ControlEventKind::ResumeFailed { message } => {
10320                tracing::warn!(
10321                    node_id = %self.node_id,
10322                    workspace_id = %address.workspace_id,
10323                    session = ?address.session,
10324                    cause = %message,
10325                    "session resume failed",
10326                );
10327            }
10328            _ => {}
10329        }
10330        let stream_chunk = agent_stream_chunk(&event);
10331        self.publish(NodeEvent::Control {
10332            address: address.clone(),
10333            event,
10334        });
10335        if let Some(chunk) = stream_chunk {
10336            self.publish_agent_stream_chunk(address.clone(), chunk);
10337        }
10338        if revokes_harness_mcp {
10339            SessionLifecycleHook::on_session_ended(self, &address);
10340        }
10341    }
10342
10343    fn reconcile_managed_record(
10344        &self,
10345        record_id: &SessionRecordId,
10346        address: &SessionAddress,
10347        event: &ControlEvent,
10348        identity_admitted: bool,
10349        resume_command_rejected: bool,
10350    ) {
10351        let _transaction = self
10352            .state_transaction
10353            .lock()
10354            .unwrap_or_else(|poisoned| poisoned.into_inner());
10355        let observed_identity = match &event.event {
10356            ControlEventKind::ProviderEvent {
10357                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10358                ..
10359            } if identity_admitted => Some(identity.clone()),
10360            _ => None,
10361        };
10362        let identity_was_observed = observed_identity.is_some();
10363        let replacement_id = observed_identity
10364            .as_ref()
10365            .and_then(|_| self.allocate_record_id().ok());
10366        let mut upserts = Vec::new();
10367        let mut removals = Vec::new();
10368        let mut rebind = None;
10369        let original_records;
10370        let original_materializations = self
10371            .materializations
10372            .lock()
10373            .unwrap_or_else(|poisoned| poisoned.into_inner())
10374            .clone();
10375        {
10376            let mut records = self
10377                .session_records
10378                .lock()
10379                .unwrap_or_else(|poisoned| poisoned.into_inner());
10380            original_records = records.records.clone();
10381            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10382                return;
10383            };
10384            if let Some(identity) = observed_identity {
10385                let existing_id = records
10386                    .records
10387                    .values()
10388                    .find(|record| {
10389                        record.record_id != *record_id
10390                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10391                                session_registry::same_provider_session(
10392                                    &record.provider,
10393                                    record_identity,
10394                                    &current_snapshot.provider,
10395                                    &identity,
10396                                )
10397                            })
10398                    })
10399                    .map(|record| record.record_id.clone());
10400                if let Some(existing_id) = existing_id {
10401                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10402                        record.mode == current_snapshot.mode
10403                            && record.workspace_id == current_snapshot.workspace_id
10404                            && record.environment_profile == current_snapshot.environment_profile
10405                            && record.bundle == current_snapshot.bundle
10406                            && record.context_id == current_snapshot.context_id
10407                            && record.context == current_snapshot.context
10408                            && record
10409                                .canonical_root
10410                                .as_utf8()
10411                                .zip(current_snapshot.canonical_root.as_utf8())
10412                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10413                    });
10414                    if !scope_matches {
10415                        if let Some(current) = records.records.get_mut(record_id) {
10416                            current.state = ManagedSessionState::Unavailable;
10417                            current.last_error =
10418                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10419                            current.updated_at_unix_ms = unix_time_ms();
10420                            upserts.push(current.clone());
10421                        }
10422                    } else {
10423                        let existing_is_active = records
10424                            .records
10425                            .get(&existing_id)
10426                            .is_some_and(|record| record.active_session.is_some());
10427                        if existing_is_active {
10428                            if let Some(current) = records.records.get_mut(record_id) {
10429                                current.state = ManagedSessionState::Unavailable;
10430                                current.last_error =
10431                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10432                                current.updated_at_unix_ms = unix_time_ms();
10433                                upserts.push(current.clone());
10434                            }
10435                        } else {
10436                            if current_snapshot.provider_session.is_some() {
10437                                if let Some(current) = records.records.get_mut(record_id) {
10438                                    current.active_session = None;
10439                                    current.state = ManagedSessionState::Dormant;
10440                                    current.updated_at_unix_ms = unix_time_ms();
10441                                    upserts.push(current.clone());
10442                                }
10443                            } else {
10444                                records.records.remove(record_id);
10445                                removals.push(record_id.clone());
10446                            }
10447                            if let Some(existing) = records.records.get_mut(&existing_id) {
10448                                existing.provider_session = Some(identity.clone());
10449                                existing.active_session = Some(address.clone());
10450                                existing.state = ManagedSessionState::Live;
10451                                existing.updated_at_unix_ms = unix_time_ms();
10452                                existing.last_error = None;
10453                                upserts.push(existing.clone());
10454                            }
10455                            rebind = Some(existing_id);
10456                        }
10457                    }
10458                } else if current_snapshot
10459                    .provider_session
10460                    .as_ref()
10461                    .is_some_and(|current| {
10462                        !session_registry::same_provider_session(
10463                            &current_snapshot.provider,
10464                            current,
10465                            &current_snapshot.provider,
10466                            &identity,
10467                        )
10468                    })
10469                {
10470                    if let Some(current) = records.records.get_mut(record_id) {
10471                        current.active_session = None;
10472                        if current.bundle.is_some() || current.context.is_some() {
10473                            let had_bundle = current.bundle.is_some();
10474                            current.provider_session = None;
10475                            current.bundle = None;
10476                            current.context_id = None;
10477                            current.context = None;
10478                            current.state = ManagedSessionState::Unavailable;
10479                            current.last_error = Some(if had_bundle {
10480                                "bundle-unavailable".to_owned()
10481                            } else {
10482                                "context-unavailable".to_owned()
10483                            });
10484                        } else {
10485                            current.state = ManagedSessionState::Dormant;
10486                        }
10487                        current.updated_at_unix_ms = unix_time_ms();
10488                        upserts.push(current.clone());
10489                    }
10490                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10491                        if let Some(current) = records.records.get_mut(record_id) {
10492                            current.state = ManagedSessionState::Unavailable;
10493                            current.last_error =
10494                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10495                            current.updated_at_unix_ms = unix_time_ms();
10496                            upserts.push(current.clone());
10497                        }
10498                    } else if let Some(new_record_id) = replacement_id {
10499                        let now = unix_time_ms();
10500                        let replacement = ManagedSessionRecord {
10501                            record_id: new_record_id.clone(),
10502                            display_name: format!(
10503                                "{} #{}",
10504                                current_snapshot.provider.as_str(),
10505                                records
10506                                    .records
10507                                    .values()
10508                                    .filter(|record| {
10509                                        record.provider == current_snapshot.provider
10510                                    })
10511                                    .count()
10512                                    .saturating_add(1),
10513                            ),
10514                            provider: current_snapshot.provider.clone(),
10515                            mode: current_snapshot.mode,
10516                            state: ManagedSessionState::Live,
10517                            workspace_id: current_snapshot.workspace_id,
10518                            canonical_root: current_snapshot.canonical_root,
10519                            provider_session: Some(identity),
10520                            active_session: Some(address.clone()),
10521                            environment_profile: current_snapshot.environment_profile,
10522                            bundle: current_snapshot.bundle,
10523                            context_id: current_snapshot.context_id,
10524                            context: current_snapshot.context,
10525                            // This is a fresh record_id representing the session under its
10526                            // newly-observed identity; it has not yet produced any clean
10527                            // exit of its own, so it starts with no exported pack (the old
10528                            // record, still addressable under its own record_id, keeps
10529                            // whatever exported_context it already had).
10530                            exported_context: None,
10531                            task_binding: current_snapshot.task_binding,
10532                            created_at_unix_ms: now,
10533                            updated_at_unix_ms: now,
10534                            last_error: None,
10535                        };
10536                        records
10537                            .records
10538                            .insert(new_record_id.clone(), replacement.clone());
10539                        upserts.push(replacement);
10540                        rebind = Some(new_record_id);
10541                    } else if let Some(current) = records.records.get_mut(record_id) {
10542                        current.state = ManagedSessionState::Unavailable;
10543                        current.last_error =
10544                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10545                        upserts.push(current.clone());
10546                    }
10547                } else if let Some(current) = records.records.get_mut(record_id) {
10548                    current.provider_session = Some(identity);
10549                    current.active_session = Some(address.clone());
10550                    current.state = ManagedSessionState::Live;
10551                    current.updated_at_unix_ms = unix_time_ms();
10552                    current.last_error = None;
10553                    upserts.push(current.clone());
10554                }
10555            } else if let Some(current) = records.records.get_mut(record_id) {
10556                if resume_command_rejected {
10557                    current.active_session = None;
10558                    current.state = detached_record_state(current);
10559                    current.updated_at_unix_ms = unix_time_ms();
10560                    current.last_error =
10561                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10562                    upserts.push(current.clone());
10563                } else {
10564                    match &event.event {
10565                    ControlEventKind::Running { .. }
10566                    | ControlEventKind::ResumeAuthorized { .. }
10567                    | ControlEventKind::Resumed { .. } => {
10568                        current.active_session = Some(address.clone());
10569                        current.state = active_record_state(
10570                            identity_admitted,
10571                            current.provider_session.is_some(),
10572                        );
10573                        current.updated_at_unix_ms = unix_time_ms();
10574                        current.last_error = None;
10575                        upserts.push(current.clone());
10576                    }
10577                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10578                        current.active_session = None;
10579                        current.state = detached_record_state(current);
10580                        current.updated_at_unix_ms = unix_time_ms();
10581                        upserts.push(current.clone());
10582                    }
10583                    ControlEventKind::Failed { message }
10584                    | ControlEventKind::ResumeFailed { message } => {
10585                        current.active_session = None;
10586                        current.state = detached_record_state(current);
10587                        current.updated_at_unix_ms = unix_time_ms();
10588                        current.last_error = Some(
10589                            session_registry::sanitized_record_error_summary(message),
10590                        );
10591                        upserts.push(current.clone());
10592                    }
10593                    ControlEventKind::ResumeDenied { reason } => {
10594                        current.active_session = None;
10595                        current.state = detached_record_state(current);
10596                        current.updated_at_unix_ms = unix_time_ms();
10597                        current.last_error = Some(
10598                            session_registry::sanitized_record_error_summary(reason),
10599                        );
10600                        upserts.push(current.clone());
10601                    }
10602                        _ => {}
10603                    }
10604                }
10605            }
10606        }
10607        let materialization_owner_record = identity_was_observed.then(|| {
10608            rebind.clone().unwrap_or_else(|| record_id.clone())
10609        }).and_then(|candidate| {
10610            self.session_records
10611                .lock()
10612                .unwrap_or_else(|poisoned| poisoned.into_inner())
10613                .records
10614                .get(&candidate)
10615                .filter(|record| {
10616                    record.provider_session.is_some()
10617                        && record.active_session.as_ref() == Some(address)
10618                })
10619                .map(|_| candidate)
10620        });
10621        if let Some(owner_record_id) = materialization_owner_record {
10622            let materialization_id = self
10623                .session_bindings
10624                .lock()
10625                .unwrap_or_else(|poisoned| poisoned.into_inner())
10626                .get(&address.session.instance_id)
10627                .and_then(|binding| binding.materialization_id.clone());
10628            if let Some(materialization_id) = materialization_id {
10629                let mut transfer_failed = false;
10630                {
10631                    let mut materializations = self
10632                        .materializations
10633                        .lock()
10634                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10635                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10636                        match ownership.owner() {
10637                            MaterializationOwner::Session {
10638                                incarnation_id,
10639                                instance_id,
10640                                generation,
10641                            } if *incarnation_id == self.incarnation_id
10642                                && *instance_id == address.session.instance_id
10643                                && *generation == address.session.generation =>
10644                            {
10645                                transfer_failed = ownership
10646                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10647                                    .is_err();
10648                            }
10649                            MaterializationOwner::Record { record_id }
10650                                if record_id == &owner_record_id => {}
10651                            MaterializationOwner::Record { record_id } => {
10652                                let expected = record_id.clone();
10653                                transfer_failed = ownership
10654                                    .transfer_record_owner(
10655                                        &expected,
10656                                        owner_record_id.clone(),
10657                                        unix_time_ms(),
10658                                    )
10659                                    .is_err();
10660                            }
10661                            _ => transfer_failed = true,
10662                        }
10663                        if transfer_failed {
10664                            let _ = ownership.mark_recovery_required(unix_time_ms());
10665                        }
10666                    } else {
10667                        transfer_failed = true;
10668                    }
10669                }
10670                if transfer_failed {
10671                    if let Some(record) = self
10672                        .session_records
10673                        .lock()
10674                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10675                        .records
10676                        .get_mut(&owner_record_id)
10677                    {
10678                        record.state = ManagedSessionState::Unavailable;
10679                        record.last_error = Some("environment-profile-unavailable".to_owned());
10680                        record.updated_at_unix_ms = unix_time_ms();
10681                        upserts.push(record.clone());
10682                    }
10683                }
10684            }
10685        }
10686        if let Some(new_record_id) = rebind.clone() {
10687            if let Some(binding) = self
10688                .session_bindings
10689                .lock()
10690                .unwrap_or_else(|poisoned| poisoned.into_inner())
10691                .get_mut(&address.session.instance_id)
10692            {
10693                binding.record_id = Some(new_record_id);
10694            }
10695        }
10696        if upserts.is_empty() && removals.is_empty() {
10697            return;
10698        }
10699        if self.persist_state_locked().is_err() {
10700            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10701            let mut records = self
10702                .session_records
10703                .lock()
10704                .unwrap_or_else(|poisoned| poisoned.into_inner());
10705            records.records = original_records;
10706            *self
10707                .materializations
10708                .lock()
10709                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10710            if let Some(current) = records.records.get_mut(record_id) {
10711                current.state = ManagedSessionState::Unavailable;
10712                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10713                current.updated_at_unix_ms = unix_time_ms();
10714                upserts = vec![current.clone()];
10715            } else {
10716                upserts.clear();
10717            }
10718            removals.clear();
10719            drop(records);
10720            if let Some(binding) = self
10721                .session_bindings
10722                .lock()
10723                .unwrap_or_else(|poisoned| poisoned.into_inner())
10724                .get_mut(&address.session.instance_id)
10725            {
10726                binding.record_id = Some(record_id.clone());
10727            }
10728            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10729        }
10730        for record in upserts {
10731            self.publish_record(record);
10732        }
10733        for record_id in removals {
10734            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10735        }
10736    }
10737
10738    fn snapshot(&self) -> NodeSnapshot {
10739        let control = self.handle.snapshot();
10740        let bindings = self
10741            .session_bindings
10742            .lock()
10743            .unwrap_or_else(|poisoned| poisoned.into_inner())
10744            .clone();
10745        let workspace_roots = self
10746            .workspaces
10747            .read()
10748            .unwrap_or_else(|poisoned| poisoned.into_inner())
10749            .clone();
10750        let environment_profiles = self
10751            .environment_profiles
10752            .read()
10753            .unwrap_or_else(|poisoned| poisoned.into_inner());
10754        let mut workspaces = workspace_roots
10755            .iter()
10756            .map(|(workspace_id, canonical_root)| {
10757                (
10758                    workspace_id.clone(),
10759                    WorkspaceSnapshot {
10760                        workspace_id: workspace_id.clone(),
10761                        canonical_root: opaque_windows_path(canonical_root.clone()),
10762                        sessions: Vec::new(),
10763                        worktree_service_mode: Some(
10764                            self.worktree_service_modes
10765                                .get(workspace_id)
10766                                .copied()
10767                                .unwrap_or(WorktreeServiceMode::Manual),
10768                        ),
10769                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10770                            profiles: self
10771                                .managed_worktree_profiles
10772                                .get(workspace_id)
10773                                .into_iter()
10774                                .flat_map(BTreeMap::values)
10775                                .map(|profile| ManagedWorktreeProfileSummary {
10776                                    id: profile.profile_id().clone(),
10777                                    revision: profile.revision().clone(),
10778                                    retention: profile.retention(),
10779                                })
10780                                .collect(),
10781                        }),
10782                    },
10783                )
10784            })
10785            .collect::<BTreeMap<_, _>>();
10786        let mut agent_progress = Vec::new();
10787        for session in &control.sessions {
10788            let Some(binding) = bindings.get(&session.instance_id) else {
10789                continue;
10790            };
10791            if binding.generation != session.generation {
10792                continue;
10793            }
10794            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10795                let address = SessionAddress {
10796                    workspace_id: binding.workspace_id.clone(),
10797                    session: SessionKey {
10798                        instance_id: session.instance_id,
10799                        generation: session.generation,
10800                    },
10801                };
10802                if let Some(progress) =
10803                    agent_progress_from_provider_snapshot(address, &session.provider)
10804                {
10805                    agent_progress.push(progress);
10806                }
10807                workspace.sessions.push(session.clone());
10808            }
10809        }
10810        NodeSnapshot {
10811            node_id: self.node_id.clone(),
10812            enabled_providers: self.enabled_providers.clone(),
10813            provider_runtime_statuses: {
10814                let updates = self
10815                    .provider_runtime_status_updates
10816                    .read()
10817                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10818                ProviderRuntimeStatuses::new(
10819                    self.provider_runtime_statuses
10820                        .iter()
10821                        .filter(|status| !updates.contains_key(status.provider()))
10822                        .cloned()
10823                        .chain(updates.values().cloned()),
10824                )
10825                .expect("refreshed provider runtime inventory remains bounded and unique")
10826            },
10827            workspaces: workspaces.into_values().collect(),
10828            session_records: self
10829                .session_records
10830                .lock()
10831                .unwrap_or_else(|poisoned| poisoned.into_inner())
10832                .records
10833                .values()
10834                .cloned()
10835                .collect(),
10836            managed_worktrees: self.managed_worktrees
10837                .lock()
10838                .unwrap_or_else(|poisoned| poisoned.into_inner())
10839                .snapshots(),
10840            launch_inventory: Some(LaunchInventory {
10841                spawn_profiles: Some(
10842                    self.spawn_profiles
10843                        .iter()
10844                        .filter_map(|profile| {
10845                            resolved_spawn_profile_summary(profile, &environment_profiles)
10846                        })
10847                        .collect(),
10848                ),
10849                bundles: Some(
10850                    self.bundle_catalog
10851                        .read()
10852                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10853                        .iter()
10854                        .map(NodeBundle::receipt)
10855                        .collect(),
10856                ),
10857                // Opaque station allowlist ids from empty-default catalog /
10858                // optional startup loader. Never secrets. Dig2 probe stays stub.
10859                network_allowlists: {
10860                    let catalog = self
10861                        .network_allowlist_catalog
10862                        .read()
10863                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10864                    if catalog.is_empty() {
10865                        None
10866                    } else {
10867                        Some(catalog.ids().cloned().collect())
10868                    }
10869                },
10870            }),
10871            agent_progress,
10872        }
10873    }
10874
10875    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10876        assert!(
10877            !matches!(
10878                &event,
10879                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10880            ),
10881            "terminal frame and agent stream events must use their own dedicated live channel",
10882        );
10883        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10884        let sequence = history
10885            .last_sequence
10886            .checked_add(1)
10887            .expect("node event sequence exhausted");
10888        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10889            if let Some(evicted) = history.events.pop_front() {
10890                history.replay_floor_sequence = evicted
10891                    .sequence
10892                    .checked_add(1)
10893                    .expect("node replay floor sequence exhausted");
10894                history.removed_record_providers.remove(&evicted.sequence);
10895            }
10896        }
10897        match &event {
10898            NodeEvent::HarnessMcpReadCall { .. } => {
10899                panic!("transient harness MCP calls must not enter durable event history")
10900            }
10901            NodeEvent::SessionRecordUpserted { record } => {
10902                history
10903                    .record_providers
10904                    .insert(record.record_id.clone(), record.provider.clone());
10905            }
10906            NodeEvent::SessionRecordRemoved { record_id } => {
10907                if let Some(provider) = history.record_providers.remove(record_id) {
10908                    history.removed_record_providers.insert(sequence, provider);
10909                }
10910            }
10911            NodeEvent::ManagedWorktreeUpserted { .. }
10912            | NodeEvent::ManagedWorktreeRemoved { .. }
10913            | NodeEvent::Control { .. }
10914            | NodeEvent::ControllerChanged { .. }
10915            | NodeEvent::SessionRecordHistorySummarized { .. }
10916            | NodeEvent::WorkspaceAdded { .. }
10917            | NodeEvent::WorkspaceRemoved { .. }
10918            | NodeEvent::ResyncRequired { .. }
10919            | NodeEvent::AgentStream { .. }
10920            | NodeEvent::TerminalFrame { .. } => {}
10921        }
10922        let envelope = NodeEventEnvelope { sequence, event };
10923        history.events.push_back(envelope.clone());
10924        history.last_sequence = sequence;
10925        let _ = self.event_tx.send(envelope.clone());
10926        drop(history);
10927        envelope
10928    }
10929
10930    /// Publishes a transient (never durable) `NodeEvent` -- today only
10931    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10932    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10933    /// why this must never ride the shared `event_tx` bus). Returns whether
10934    /// the send reached at least one subscriber: `broadcast::Sender::send`
10935    /// fails in exactly one way, no active receiver at that instant, and
10936    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10937    /// return to fail the pending call immediately rather than leave it to
10938    /// expire silently at `LOCAL_READ_TIMEOUT`/
10939    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10940    fn publish_transient(&self, event: NodeEvent) -> bool {
10941        assert!(event.requires_harness_mcp_proxy_capability());
10942        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10943        let envelope = NodeEventEnvelope { sequence: 0, event };
10944        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10945            Ok(_) => true,
10946            Err(broadcast::error::SendError(undelivered)) => {
10947                if let Some(NodeEventEnvelope {
10948                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10949                    ..
10950                }) = undelivered.first() {
10951                    // `request.content_type` only, never a per-tool label --
10952                    // this crate does not decode `request.body` and does not
10953                    // hold `hatchery-harness-api` (Nested Control Plane
10954                    // doctrine, Law 3; see this crate's dependency on
10955                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10956                    tracing::warn!(
10957                        call_id = call_id.as_str(),
10958                        content_type = ?request.content_type,
10959                        cause = "no-subscribers",
10960                        "harness MCP proxy event could not be published",
10961                    );
10962                }
10963                false
10964            }
10965        }
10966    }
10967
10968    fn publish_terminal_frames(&self) {
10969        let snapshot = self.handle.snapshot();
10970        let bindings = self
10971            .session_bindings
10972            .lock()
10973            .unwrap_or_else(|poisoned| poisoned.into_inner());
10974        let candidates = snapshot
10975            .sessions
10976            .iter()
10977            .filter_map(|session| {
10978                let binding = bindings.get(&session.instance_id)?;
10979                if binding.generation != session.generation {
10980                    return None;
10981                }
10982                let frame = session.terminal_frame.clone()?;
10983                Some((
10984                    SessionAddress {
10985                        workspace_id: binding.workspace_id.clone(),
10986                        session: SessionKey {
10987                            instance_id: session.instance_id,
10988                            generation: session.generation,
10989                        },
10990                    },
10991                    frame,
10992                ))
10993            })
10994            .collect();
10995        drop(bindings);
10996        self.publish_terminal_frame_candidates(candidates);
10997    }
10998
10999    fn publish_terminal_frame_candidates(
11000        &self,
11001        candidates: Vec<(SessionAddress, TerminalFrame)>,
11002    ) {
11003        let mut watermarks = self
11004            .terminal_frame_watermarks
11005            .lock()
11006            .unwrap_or_else(|poisoned| poisoned.into_inner());
11007        let mut events = Vec::with_capacity(candidates.len());
11008        for (address, frame) in candidates {
11009            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11010            let advanced = watermarks
11011                .get(&address.session.instance_id)
11012                .map_or(true, |(current_address, sequence)| {
11013                    current_address != &address || frame.sequence > *sequence
11014                });
11015            if !advanced {
11016                continue;
11017            }
11018            if is_first_frame {
11019                // This is the first PTY output this node has observed for
11020                // this session -- a one-time event per spawn, not per-frame
11021                // chatter (the watermark makes sure of that). A session that
11022                // reaches this line with no controller attached anywhere on
11023                // the node is running unobserved: exactly how processes from
11024                // repeated spawn attempts went orphaned for hours without a
11025                // single line about it.
11026                if self.controller_state().is_none() {
11027                    tracing::warn!(
11028                        node_id = %self.node_id,
11029                        workspace_id = %address.workspace_id,
11030                        session = ?address.session,
11031                        terminal_size = ?frame.size,
11032                        "session producing output with no controller attached",
11033                    );
11034                } else {
11035                    tracing::info!(
11036                        node_id = %self.node_id,
11037                        workspace_id = %address.workspace_id,
11038                        session = ?address.session,
11039                        terminal_size = ?frame.size,
11040                        "session produced its first output",
11041                    );
11042                }
11043            }
11044            watermarks.insert(
11045                address.session.instance_id,
11046                (address.clone(), frame.sequence),
11047            );
11048            if let Some((at, reported)) = self
11049                .pty_input_marks
11050                .lock()
11051                .unwrap_or_else(|poisoned| poisoned.into_inner())
11052                .get_mut(&address.session.instance_id)
11053            {
11054                // First frame after an input: PTY write -> output published.
11055                if !*reported && at.elapsed() < Duration::from_secs(5) {
11056                    *reported = true;
11057                    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");
11058                }
11059            }
11060            events.push(NodeEvent::TerminalFrame { address, frame });
11061        }
11062        drop(watermarks);
11063        if events.is_empty() {
11064            return;
11065        }
11066        let mut history = self
11067            .history
11068            .lock()
11069            .unwrap_or_else(|poisoned| poisoned.into_inner());
11070        let mut envelopes = Vec::with_capacity(events.len());
11071        for event in events {
11072            let sequence = history
11073                .last_sequence
11074                .checked_add(1)
11075                .expect("node event sequence exhausted");
11076            history.last_sequence = sequence;
11077            envelopes.push(NodeEventEnvelope { sequence, event });
11078        }
11079        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11080        drop(history);
11081    }
11082
11083    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11084    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11085    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11086    /// counter every other event kind shares, never a counter of its own --
11087    /// `queue_connection_event`/`queue_connection_event_batch` compare
11088    /// `envelope.sequence` against one `discard_events_through` watermark
11089    /// regardless of which broadcast channel it arrived on, so a
11090    /// per-channel counter would desynchronize that comparison.
11091    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11092        let mut history = self
11093            .history
11094            .lock()
11095            .unwrap_or_else(|poisoned| poisoned.into_inner());
11096        let sequence = history
11097            .last_sequence
11098            .checked_add(1)
11099            .expect("node event sequence exhausted");
11100        history.last_sequence = sequence;
11101        drop(history);
11102        // Debug, not info/warn -- this fires on every single chunk, but it
11103        // is exactly the counter the next drop-measurement pass needs: what
11104        // the node actually emitted (`source_sequence`, `kind`) versus what
11105        // a harness subscriber's own forwarder later logs having received
11106        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11107        // event"), so a gap between the two logs names itself instead of
11108        // needing a new counter added ad hoc.
11109        tracing::debug!(
11110            source_sequence = chunk.source_sequence,
11111            kind = agent_stream_chunk_kind_label(&chunk.kind),
11112            envelope_sequence = sequence,
11113            "publishing agent stream chunk",
11114        );
11115        let envelope = NodeEventEnvelope {
11116            sequence,
11117            event: NodeEvent::AgentStream { address, chunk },
11118        };
11119        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11120    }
11121
11122    fn current_sequence(&self) -> u64 {
11123        self.history
11124            .lock()
11125            .unwrap_or_else(|poisoned| poisoned.into_inner())
11126            .last_sequence
11127    }
11128
11129    fn controller_state(&self) -> Option<ControllerState> {
11130        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11131        let lease = controller.as_ref().copied()?;
11132        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11133        if remaining.is_zero() {
11134            *controller = None;
11135            return None;
11136        }
11137        Some(ControllerState {
11138            connection_id: lease.connection_id,
11139            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11140        })
11141    }
11142
11143    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11144        if role != ClientRole::Operator {
11145            tracing::warn!(
11146                node_id = %self.node_id,
11147                connection_id,
11148                "controller lease rejected: observer connections cannot acquire control",
11149            );
11150            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11151        }
11152        let lease_ms = if lease_ms == 0 {
11153            DEFAULT_CONTROLLER_LEASE_MS
11154        } else {
11155            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11156        };
11157        let now = Instant::now();
11158        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11159        if let Some(active) = controller.as_ref() {
11160            if active.connection_id != connection_id && active.expires_at > now {
11161                tracing::warn!(
11162                    node_id = %self.node_id,
11163                    connection_id,
11164                    holder_connection_id = active.connection_id,
11165                    "controller lease rejected: another operator holds it",
11166                );
11167                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11168            }
11169        }
11170        // A reconnect (this connection already holds the lease, renewing
11171        // before expiry) happens on a periodic cadence; logging it at INFO
11172        // every renewal would be exactly the heartbeat chatter this crate
11173        // family's level discipline forbids. A genuine attach -- the
11174        // controller identity actually changing -- is a state change and
11175        // gets INFO; a same-connection renewal gets DEBUG.
11176        let is_new_attach = match controller.as_ref() {
11177            Some(active) => active.connection_id != connection_id,
11178            None => true,
11179        };
11180        *controller = Some(ControllerLease {
11181            connection_id,
11182            expires_at: now + Duration::from_millis(lease_ms),
11183        });
11184        drop(controller);
11185        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11186        if is_new_attach {
11187            tracing::info!(
11188                node_id = %self.node_id,
11189                connection_id,
11190                lease_ms,
11191                "controller lease acquired",
11192            );
11193        } else {
11194            tracing::debug!(
11195                node_id = %self.node_id,
11196                connection_id,
11197                lease_ms,
11198                "controller lease renewed",
11199            );
11200        }
11201        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11202        Ok(state)
11203    }
11204
11205    fn release_controller(&self, connection_id: u64) -> bool {
11206        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11207        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11208        if released {
11209            *controller = None;
11210        }
11211        drop(controller);
11212        if released {
11213            tracing::info!(
11214                node_id = %self.node_id,
11215                connection_id,
11216                "controller lease released",
11217            );
11218            self.publish(NodeEvent::ControllerChanged { controller: None });
11219        }
11220        released
11221    }
11222
11223    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11224        if role != ClientRole::Operator {
11225            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11226        }
11227        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11228        let now = Instant::now();
11229        match controller.as_ref().copied() {
11230            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11231            Some(active) if active.expires_at <= now => {
11232                *controller = None;
11233                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11234            }
11235            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11236        }
11237    }
11238
11239    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11240        if !self
11241            .workspaces
11242            .read()
11243            .unwrap_or_else(|poisoned| poisoned.into_inner())
11244            .contains_key(&address.workspace_id)
11245        {
11246            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11247        }
11248        let binding = self
11249            .session_bindings
11250            .lock()
11251            .unwrap_or_else(|poisoned| poisoned.into_inner())
11252            .get(&address.session.instance_id)
11253            .cloned()
11254            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11255        if binding.workspace_id != address.workspace_id {
11256            return Err(failure(
11257                NodeFailureCode::SessionWorkspaceMismatch,
11258                "session belongs to another workspace",
11259            ));
11260        }
11261        if binding.generation != address.session.generation {
11262            return Err(failure(
11263                NodeFailureCode::StaleGeneration,
11264                &format!(
11265                    "stale session generation: expected {}, received {}",
11266                    binding.generation.0,
11267                    address.session.generation.0,
11268                ),
11269            ));
11270        }
11271        let snapshot = self.handle.snapshot();
11272        let Some(current) = snapshot
11273            .sessions
11274            .iter()
11275            .find(|item| item.instance_id == address.session.instance_id)
11276        else {
11277            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11278        };
11279        if current.generation != address.session.generation {
11280            return Err(failure(
11281                NodeFailureCode::StaleGeneration,
11282                &format!(
11283                    "stale session generation: expected {}, received {}",
11284                    current.generation.0,
11285                    address.session.generation.0,
11286                ),
11287            ));
11288        }
11289        Ok(current.agent_id.clone())
11290    }
11291
11292    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11293        self.dispatch_envelope(self.prepare_command(command))
11294    }
11295
11296    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11297        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11298        CommandEnvelope {
11299            id: CommandId(id),
11300            command,
11301        }
11302    }
11303
11304    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11305        let command_id = envelope.id;
11306        self.handle
11307            .dispatch(envelope)
11308            .map_err(|error| match error {
11309                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11310                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11311            })?;
11312        Ok(command_id)
11313    }
11314
11315    /// Records the kernel's own reason for asynchronously refusing
11316    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11317    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11318    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11319    /// first, and if the map is still at capacity after that, the single
11320    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11321    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11322    /// is always the oldest.
11323    fn record_rejected_command(
11324        &self,
11325        command_id: CommandId,
11326        message: String,
11327        unsupported_transport: bool,
11328    ) {
11329        let now = Instant::now();
11330        let mut reasons = self.rejected_command_reasons.lock()
11331            .unwrap_or_else(|poisoned| poisoned.into_inner());
11332        reasons.retain(|_, reason| reason.expires_at > now);
11333        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11334            if let Some(&oldest) = reasons.keys().next() {
11335                reasons.remove(&oldest);
11336            }
11337        }
11338        reasons.insert(
11339            command_id,
11340            RejectedCommandReason {
11341                message,
11342                unsupported_transport,
11343                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11344            },
11345        );
11346    }
11347
11348    /// Removes and returns the kernel's reason for refusing `command_id`,
11349    /// if the drive loop has recorded one and it has not expired -- see
11350    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11351    /// wait loop calls this instead of blindly polling out its whole
11352    /// deadline when the command it is waiting on turns out to have been
11353    /// refused rather than merely slow.
11354    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11355        let now = Instant::now();
11356        let mut reasons = self.rejected_command_reasons.lock()
11357            .unwrap_or_else(|poisoned| poisoned.into_inner());
11358        match reasons.remove(&command_id) {
11359            Some(reason) if reason.expires_at > now => Some(reason),
11360            _ => None,
11361        }
11362    }
11363
11364    async fn dispatch_bounded(
11365        &self,
11366        command: ControlCommand,
11367        timeout_duration: Duration,
11368    ) -> Result<CommandId, NodeFailure> {
11369        let deadline = Instant::now() + timeout_duration;
11370        loop {
11371            match self.dispatch(command.clone()) {
11372                Ok(command_id) => return Ok(command_id),
11373                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11374                    sleep(Duration::from_millis(2)).await;
11375                }
11376                Err(error) => return Err(error),
11377            }
11378        }
11379    }
11380
11381    fn internal_session_snapshot(
11382        &self,
11383        address: &SessionAddress,
11384    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11385        self.validate_address(address)?;
11386        self.handle
11387            .snapshot()
11388            .sessions
11389            .clone()
11390            .into_iter()
11391            .find(|session| {
11392                session.instance_id == address.session.instance_id
11393                    && session.generation == address.session.generation
11394            })
11395            .ok_or_else(|| {
11396                failure(
11397                    NodeFailureCode::UnknownSession,
11398                    "session snapshot is unavailable",
11399                )
11400            })
11401    }
11402
11403    /// Confirms `address` names a live session spawned over the ACP
11404    /// transport and returns its current snapshot -- the gate every
11405    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11406    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11407    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11408    /// the session's own recorded `TransportKind` does, and that lives on
11409    /// the live snapshot, not the binding.
11410    fn require_acp_session(
11411        &self,
11412        address: &SessionAddress,
11413    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11414        let snapshot = self.internal_session_snapshot(address)?;
11415        if snapshot.transport != TransportKind::Acp {
11416            return Err(failure(
11417                NodeFailureCode::UnsupportedTransport,
11418                "this verb requires a session spawned over the ACP transport",
11419            ));
11420        }
11421        Ok(snapshot)
11422    }
11423
11424    async fn dispatch_history_bounded(
11425        &self,
11426        address: &SessionAddress,
11427        command: ControlCommand,
11428        operation: HistoryOperation,
11429    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11430        self.validate_address(address)?;
11431        let started_after = self.current_sequence();
11432        let command_id = self
11433            .dispatch_bounded(
11434                command,
11435                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11436            )
11437            .await?;
11438        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11439        let mut scan_after = started_after;
11440        let mut accepted_after = None;
11441        loop {
11442            let events = self
11443                .history
11444                .lock()
11445                .unwrap_or_else(|poisoned| poisoned.into_inner())
11446                .events
11447                .iter()
11448                .filter(|envelope| envelope.sequence > scan_after)
11449                .cloned()
11450                .collect::<Vec<_>>();
11451            for envelope in events {
11452                scan_after = scan_after.max(envelope.sequence);
11453                let NodeEvent::Control {
11454                    address: event_address,
11455                    event,
11456                } = envelope.event
11457                else {
11458                    continue;
11459                };
11460                if event_address != *address {
11461                    continue;
11462                }
11463                if event.command_id == Some(command_id) {
11464                    match &event.event {
11465                        ControlEventKind::CommandRejected { .. } => {
11466                            return Err(failure(
11467                                NodeFailureCode::InvalidRequest,
11468                                "history request was rejected",
11469                            ));
11470                        }
11471                        ControlEventKind::HistoryRequested {
11472                            operation: requested,
11473                            ..
11474                        } if requested == &operation => {
11475                            accepted_after = Some(envelope.sequence);
11476                            continue;
11477                        }
11478                        _ => {}
11479                    }
11480                }
11481                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11482                    match (&operation, &event.event) {
11483                        (
11484                            HistoryOperation::Discover { .. },
11485                            ControlEventKind::HistoryDiscovered { .. },
11486                        )
11487                        | (
11488                            HistoryOperation::Load { .. },
11489                            ControlEventKind::HistoryLoaded { .. },
11490                        ) => return self.internal_session_snapshot(address),
11491                        (_, ControlEventKind::HistoryFailed { .. }) => {
11492                            return Err(failure(
11493                                NodeFailureCode::BackendOperationFailed,
11494                                "history operation failed",
11495                            ));
11496                        }
11497                        _ => {}
11498                    }
11499                }
11500            }
11501            if Instant::now() >= deadline {
11502                return Err(failure(
11503                    NodeFailureCode::BackendBusy,
11504                    if accepted_after.is_some() {
11505                        "history operation did not settle before the bounded deadline"
11506                    } else {
11507                        "history operation was not accepted before the bounded deadline"
11508                    },
11509                ));
11510            }
11511            sleep(Duration::from_millis(2)).await;
11512        }
11513    }
11514
11515    async fn discover_history(
11516        &self,
11517        address: &SessionAddress,
11518        limit: u16,
11519    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11520        let query = HistoryQuery {
11521            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11522            limit,
11523        };
11524        query
11525            .validate()
11526            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11527        let snapshot = self
11528            .dispatch_history_bounded(
11529                address,
11530                ControlCommand::DiscoverHistory {
11531                    instance_id: address.session.instance_id,
11532                    query: query.clone(),
11533                },
11534                HistoryOperation::Discover { query },
11535            )
11536            .await?;
11537        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11538            return Err(failure(
11539                NodeFailureCode::BackendOperationFailed,
11540                "history discovery settled into an invalid state",
11541            ));
11542        }
11543        Ok(snapshot.history.candidates)
11544    }
11545
11546    async fn load_history(
11547        &self,
11548        address: &SessionAddress,
11549        candidate_id: String,
11550    ) -> Result<HistorySessionRecord, NodeFailure> {
11551        validate_candidate_id(&candidate_id)
11552            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11553        let snapshot = self
11554            .dispatch_history_bounded(
11555                address,
11556                ControlCommand::LoadHistory {
11557                    instance_id: address.session.instance_id,
11558                    candidate_id: candidate_id.clone(),
11559                },
11560                HistoryOperation::Load {
11561                    candidate_id: candidate_id.clone(),
11562                },
11563            )
11564            .await?;
11565        if snapshot.history.pending.is_some()
11566            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11567        {
11568            return Err(failure(
11569                NodeFailureCode::BackendOperationFailed,
11570                "history load settled into an invalid state",
11571            ));
11572        }
11573        snapshot.history.loaded.ok_or_else(|| {
11574            failure(
11575                NodeFailureCode::BackendOperationFailed,
11576                "history load produced no bounded session",
11577            )
11578        })
11579    }
11580
11581    /// `allow_clean_detachment`: `false` for the on-demand
11582    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11583    /// managed record must stay untouched, still live, for the whole call);
11584    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11585    /// reacts to the very clean-exit event that detaches the record out from
11586    /// under this same call — see `session_record_export_target_matches`.
11587    async fn export_context_pack_for_session_record(
11588        &self,
11589        record_id: &SessionRecordId,
11590        session: &SessionAddress,
11591        allow_clean_detachment: bool,
11592    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11593        // C2 replaces a relayed failure's prose with a canned per-code
11594        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11595        // distinct refusals are indistinguishable to anyone downstream --
11596        // and a refusal here expires a continuation, which keeps no field
11597        // for why. The prose stays on the node, where it was written.
11598        let outcome = self
11599            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11600            .await;
11601        if let Err(failure) = &outcome {
11602            tracing::warn!(
11603                record = %record_id,
11604                code = ?failure.code,
11605                reason = %failure.message,
11606                "session-record context-pack export refused",
11607            );
11608        }
11609        outcome
11610    }
11611
11612    async fn export_context_pack_for_session_record_inner(
11613        &self,
11614        record_id: &SessionRecordId,
11615        session: &SessionAddress,
11616        allow_clean_detachment: bool,
11617    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11618        // Several steps here refuse with the same BackendOperationFailed
11619        // code and no prose of their own, so which step failed is
11620        // otherwise unrecoverable -- and a refusal here expires a
11621        // continuation.
11622        let step = |name: &'static str| move |failure: NodeFailure| {
11623            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11624            failure
11625        };
11626        let record = self.record(record_id).map_err(step("record-lookup"))?;
11627        // `provider_session` is `Some` only for a provider whose resolved
11628        // runtime policy admits live identity verification (raw PTY plus a
11629        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11630        // The PTY-sidecar-observation shape (no fleet provider today, but
11631        // capability-keyed rather than vendor-locked) hardcodes
11632        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11633        // so its records go straight to `Live` with `provider_session: None`
11634        // and stay that way for the record's entire life -- that is this
11635        // shape's permanent resting state, not a race with observation, and
11636        // `discover_history`/`load_history` below do not need an identity to
11637        // do their job for it. `IdentityPending` is the actual race (an
11638        // identity-admitted provider whose observation has not landed yet):
11639        // it keeps refusing here rather than falling back, because that
11640        // provider's own history the fallback would otherwise trust is not
11641        // guaranteed to be exact without the identity it is still waiting on.
11642        let identity = match record.provider_session.clone() {
11643            Some(identity) => {
11644                if identity.key != ProviderSessionKey::SessionId {
11645                    return Err(step("provider-identity-key")(failure(
11646                        NodeFailureCode::BackendOperationFailed,
11647                        "managed session provider identity cannot bind history exactly",
11648                    )));
11649                }
11650                Some(identity)
11651            }
11652            None if record.state == ManagedSessionState::IdentityPending => {
11653                return Err(step("provider-session-identity")(failure(
11654                    NodeFailureCode::BackendOperationFailed,
11655                    "managed session has not yet observed its provider session identity",
11656                )));
11657            }
11658            None => None,
11659        };
11660        self.revalidate_session_record_context_export(
11661            &record,
11662            identity.as_ref(),
11663            session,
11664            allow_clean_detachment,
11665        ).map_err(step("revalidate-before-discovery"))?;
11666        let candidates = self
11667            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11668            .await
11669            .map_err(step("discover-history"))?;
11670        // With no verified identity to filter by, the discovered set must
11671        // still resolve to exactly one candidate: the same ambiguity refusal
11672        // an identity filter would otherwise produce is the only safety
11673        // property left available for this shape.
11674        let candidate_id = select_history_candidate(
11675            &candidates,
11676            identity.as_ref().map(|identity| identity.id.as_str()),
11677        ).map_err(step("unique-candidate"))?;
11678        let loaded = self.load_history(session, candidate_id.clone()).await
11679            .map_err(step("load-history"))?;
11680        if let Some(identity) = identity.as_ref() {
11681            if loaded.session_id != identity.id {
11682                return Err(failure(
11683                    NodeFailureCode::SessionRecordConflict,
11684                    "loaded history identity does not match the managed session record",
11685                ));
11686            }
11687        }
11688        self.revalidate_session_record_context_export(
11689            &record,
11690            identity.as_ref(),
11691            session,
11692            allow_clean_detachment,
11693        )?;
11694        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11695            .map_err(step("revalidate-loaded-history"))?;
11696        let pack = self.materialize_context_pack(session).await
11697            .map_err(step("materialize-context-pack"))?;
11698        self.commit_context_pack_for_session_record(
11699            &record,
11700            identity.as_ref(),
11701            session,
11702            &candidate_id,
11703            &loaded,
11704            pack,
11705            allow_clean_detachment,
11706        )
11707    }
11708
11709    async fn export_context_pack(
11710        &self,
11711        address: &SessionAddress,
11712    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11713        let pack = self.materialize_context_pack(address).await?;
11714        self.commit_context_pack(pack)
11715    }
11716
11717    async fn materialize_context_pack(
11718        &self,
11719        address: &SessionAddress,
11720    ) -> Result<NodeContextPack, NodeFailure> {
11721        let snapshot = self.internal_session_snapshot(address)?;
11722        if !context_pack_source_status_is_usable(&snapshot.status) {
11723            return Err(failure(
11724                NodeFailureCode::InvalidRequest,
11725                "context export requires a running or successfully exited source session",
11726            ));
11727        }
11728        if snapshot.history.pending.is_some()
11729            || snapshot.history.loaded_candidate_id.is_none()
11730        {
11731            return Err(failure(
11732                NodeFailureCode::InvalidRequest,
11733                "context export requires settled loaded history",
11734            ));
11735        }
11736        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11737            failure(
11738                NodeFailureCode::InvalidRequest,
11739                "context export requires settled loaded history",
11740            )
11741        })?;
11742        let source_provider = snapshot.agent_id.clone();
11743        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11744        let repository = self
11745            .context_pack_repository(&address.workspace_id)
11746            .await?;
11747        let current = self.internal_session_snapshot(address)?;
11748        if !context_pack_source_status_is_usable(&current.status)
11749            || current.agent_id != source_provider
11750            || current.history.pending.is_some()
11751            || current.history.loaded_candidate_id != loaded_candidate_id
11752            || current.history.loaded.as_ref() != Some(&loaded)
11753        {
11754            return Err(failure(
11755                NodeFailureCode::ContextPackMaterializationFailed,
11756                "context source changed during bounded repository capture",
11757            ));
11758        }
11759        NodeContextPack::export_with_repository(
11760            ContextPackLineageReceipt {
11761                source_node_id: self.node_id.clone(),
11762                source_session: address.clone(),
11763                source_provider,
11764            },
11765            &loaded,
11766            Some(repository),
11767        )
11768        .map_err(|_| {
11769            failure(
11770                NodeFailureCode::ContextPackMaterializationFailed,
11771                "bounded context pack export failed",
11772            )
11773        })
11774    }
11775
11776    /// Write-through to the durable [`ContextPackStore`], if one is
11777    /// configured (`state_path` is `Some`). A node with no durable state
11778    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11779    /// open at all and this is a no-op, matching `persist_state`'s own
11780    /// "durability optional, degrade gracefully when unconfigured" idiom.
11781    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11782        let mut store = self
11783            .context_pack_store
11784            .lock()
11785            .unwrap_or_else(|poisoned| poisoned.into_inner());
11786        let Some(store) = store.as_mut() else {
11787            return Ok(());
11788        };
11789        store.commit(pack).map_err(|_| {
11790            failure(
11791                NodeFailureCode::ContextPackMaterializationFailed,
11792                "durable context pack commit failed",
11793            )
11794        })
11795    }
11796
11797    fn commit_context_pack(
11798        &self,
11799        pack: NodeContextPack,
11800    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11801        self.commit_context_pack_durably(&pack)?;
11802        self.context_catalog
11803            .write()
11804            .unwrap_or_else(|poisoned| poisoned.into_inner())
11805            .insert(pack)
11806            .map_err(|_| {
11807                failure(
11808                    NodeFailureCode::ContextPackMaterializationFailed,
11809                    "context pack catalog is full",
11810                )
11811            })
11812    }
11813
11814    fn commit_context_pack_for_session_record(
11815        &self,
11816        expected: &ManagedSessionRecord,
11817        identity: Option<&ProviderSessionIdentity>,
11818        session: &SessionAddress,
11819        candidate_id: &str,
11820        loaded: &HistorySessionRecord,
11821        pack: NodeContextPack,
11822        allow_clean_detachment: bool,
11823    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11824        let current_root = self.workspace_root(&expected.workspace_id)?;
11825        let _transaction = self
11826            .state_transaction
11827            .lock()
11828            .unwrap_or_else(|poisoned| poisoned.into_inner());
11829        let records = self
11830            .session_records
11831            .lock()
11832            .unwrap_or_else(|poisoned| poisoned.into_inner());
11833        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11834            failure(
11835                NodeFailureCode::SessionRecordConflict,
11836                "managed session record disappeared during context export",
11837            )
11838        })?;
11839        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11840            return Err(failure(
11841                NodeFailureCode::SessionRecordConflict,
11842                "managed session record changed during context export",
11843            ));
11844        }
11845        let bindings = self
11846            .session_bindings
11847            .lock()
11848            .unwrap_or_else(|poisoned| poisoned.into_inner());
11849        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11850            failure(
11851                NodeFailureCode::SessionRecordConflict,
11852                "managed session runtime binding disappeared during context export",
11853            )
11854        })?;
11855        let runtime = self
11856            .handle
11857            .snapshot()
11858            .sessions
11859            .iter()
11860            .find(|snapshot| {
11861                snapshot.instance_id == session.session.instance_id
11862                    && snapshot.generation == session.session.generation
11863            })
11864            .cloned()
11865            .ok_or_else(|| {
11866                failure(
11867                    NodeFailureCode::SessionRecordConflict,
11868                    "managed session runtime disappeared during context export",
11869                )
11870            })?;
11871        let receipt = pack.receipt();
11872        if !session_record_context_export_binding_is_exact(
11873            expected,
11874            current,
11875            identity,
11876            session,
11877            binding,
11878            &runtime.agent_id,
11879            &current_root,
11880            allow_clean_detachment,
11881        ) || !session_record_context_export_source_is_usable(
11882            current,
11883            &runtime.status,
11884            allow_clean_detachment,
11885        ) || runtime.history.pending.is_some()
11886            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11887            || runtime.history.loaded.as_ref() != Some(loaded)
11888            || receipt.lineage.source_node_id != self.node_id
11889            || receipt.lineage.source_session != *session
11890            || receipt.lineage.source_provider != current.provider
11891            || receipt.source_message_count != loaded.message_count
11892        {
11893            return Err(failure(
11894                NodeFailureCode::SessionRecordConflict,
11895                "managed session source changed before context catalog commit",
11896            ));
11897        }
11898        self.commit_context_pack_durably(&pack)?;
11899        self.context_catalog
11900            .write()
11901            .unwrap_or_else(|poisoned| poisoned.into_inner())
11902            .insert(pack)
11903            .map_err(|_| {
11904                failure(
11905                    NodeFailureCode::ContextPackMaterializationFailed,
11906                    "context pack catalog is full",
11907                )
11908            })
11909    }
11910
11911    async fn context_pack_repository(
11912        &self,
11913        workspace_id: &WorkspaceId,
11914    ) -> Result<ContextPackRepository, NodeFailure> {
11915        let first = self
11916            .context_pack_repository_observation(workspace_id)
11917            .await?;
11918        let second = self
11919            .context_pack_repository_observation(workspace_id)
11920            .await?;
11921        stable_context_pack_repository(first, second)
11922    }
11923
11924    async fn context_pack_repository_observation(
11925        &self,
11926        workspace_id: &WorkspaceId,
11927    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11928        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11929        let head = if git.is_repository {
11930            let root = self.workspace_root(workspace_id)?;
11931            match resolve_base_commit_with_timeout(
11932                &root,
11933                "HEAD",
11934                GIT_COMMAND_TIMEOUT_MS,
11935            )
11936            .await
11937            {
11938                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11939                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11940                    ContextPackRepositoryHead::Unborn
11941                }
11942                Err(_) => {
11943                    return Err(failure(
11944                        NodeFailureCode::ContextPackMaterializationFailed,
11945                        "context pack HEAD observation failed",
11946                    ));
11947                }
11948            }
11949        } else {
11950            ContextPackRepositoryHead::NotRepository
11951        };
11952        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11953        for selected_path in CONTEXT_PACK_SELECTED_FILES {
11954            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11955                failure(
11956                    NodeFailureCode::InvalidRepositoryPath,
11957                    "context pack selected file path is invalid",
11958                )
11959            })?;
11960            match self.read_workspace_file(workspace_id.clone(), path).await {
11961                Ok(WorkspaceFileRead {
11962                    content: WorkspaceFileContent::Utf8 { text, byte_len },
11963                    ..
11964                }) => files.push(ContextPackRepositoryFileSource::utf8(
11965                    selected_path,
11966                    text,
11967                    byte_len,
11968                )),
11969                Ok(WorkspaceFileRead {
11970                    content: WorkspaceFileContent::NonUtf8 { byte_len },
11971                    ..
11972                }) => files.push(ContextPackRepositoryFileSource::skipped(
11973                    selected_path,
11974                    ContextPackSelectedFileSkipReason::NonUtf8,
11975                    Some(byte_len),
11976                )),
11977                Ok(WorkspaceFileRead {
11978                    content: WorkspaceFileContent::TooLarge { .. },
11979                    ..
11980                }) => files.push(ContextPackRepositoryFileSource::skipped(
11981                    selected_path,
11982                    ContextPackSelectedFileSkipReason::TooLarge,
11983                    None,
11984                )),
11985                Err(error) => {
11986                    let reason = if matches!(
11987                        error.code,
11988                        NodeFailureCode::InvalidRepositoryPath
11989                            | NodeFailureCode::RepositoryFileNotRegular
11990                            | NodeFailureCode::RepositoryPathUnsafe
11991                    ) {
11992                        ContextPackSelectedFileSkipReason::Unsafe
11993                    } else {
11994                        ContextPackSelectedFileSkipReason::Unavailable
11995                    };
11996                    files.push(ContextPackRepositoryFileSource::skipped(
11997                        selected_path,
11998                        reason,
11999                        None,
12000                    ));
12001                }
12002            }
12003        }
12004        Ok(ContextPackRepositoryObservation { head, git, files })
12005    }
12006
12007    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12008        let referenced_by_binding = self
12009            .session_bindings
12010            .lock()
12011            .unwrap_or_else(|poisoned| poisoned.into_inner())
12012            .values()
12013            .any(|binding| {
12014                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12015            });
12016        let referenced_by_record = self
12017            .session_records
12018            .lock()
12019            .unwrap_or_else(|poisoned| poisoned.into_inner())
12020            .records
12021            .values()
12022            .any(|record| record.context_id.as_ref() == Some(context_id));
12023        let referenced_by_materialization = self
12024            .materializations
12025            .lock()
12026            .unwrap_or_else(|poisoned| poisoned.into_inner())
12027            .values()
12028            .any(|ownership| {
12029                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12030            });
12031        if referenced_by_binding
12032            || referenced_by_record
12033            || referenced_by_materialization
12034        {
12035            return Err(failure(
12036                NodeFailureCode::ContextPackBusy,
12037                "context pack is still referenced",
12038            ));
12039        }
12040        let mut catalog = self.context_catalog
12041            .write()
12042            .unwrap_or_else(|poisoned| poisoned.into_inner());
12043        if catalog.get(context_id).is_none() {
12044            return Err(failure(
12045                NodeFailureCode::UnknownContextPack,
12046                "context pack is unavailable",
12047            ));
12048        }
12049        self.spawn_idempotency
12050            .lock()
12051            .unwrap_or_else(|poisoned| poisoned.into_inner())
12052            .entries
12053            .retain(|_, entry| !entry.value.references_context(context_id));
12054        let removed = catalog.remove(context_id);
12055        debug_assert!(removed.is_some(), "validated context pack remains present");
12056        Ok(())
12057    }
12058
12059    async fn dispatch_input_bounded(
12060        &self,
12061        address: &SessionAddress,
12062        action: InputAction,
12063    ) -> Result<(), NodeFailure> {
12064        let started_after = self.current_sequence();
12065        self.pty_input_marks
12066            .lock()
12067            .unwrap_or_else(|poisoned| poisoned.into_inner())
12068            .insert(address.session.instance_id, (Instant::now(), false));
12069        self.pty_input_wake.notify_one();
12070        let command_id = self
12071            .dispatch_bounded(
12072                ControlCommand::SendInput {
12073                    instance_id: address.session.instance_id,
12074                    action,
12075                },
12076                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12077            )
12078            .await?;
12079        // A semantic prompt may spend the provider's full readiness budget before
12080        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12081        // but strictly beyond every readiness policy in this node's catalog.
12082        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12083        let mut scan_after = started_after;
12084        let mut accepted_after = None;
12085        loop {
12086            let events = self
12087                .history
12088                .lock()
12089                .unwrap_or_else(|poisoned| poisoned.into_inner())
12090                .events
12091                .iter()
12092                .filter(|envelope| envelope.sequence > scan_after)
12093                .cloned()
12094                .collect::<Vec<_>>();
12095            for envelope in events {
12096                scan_after = scan_after.max(envelope.sequence);
12097                let NodeEvent::Control {
12098                    address: event_address,
12099                    event,
12100                } = envelope.event
12101                else {
12102                    continue;
12103                };
12104                if event_address != *address {
12105                    continue;
12106                }
12107                if event.command_id == Some(command_id) {
12108                    match &event.event {
12109                        ControlEventKind::CommandRejected { message } => {
12110                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12111                        }
12112                        ControlEventKind::InputRequested { .. } => {
12113                            accepted_after = Some(envelope.sequence);
12114                            continue;
12115                        }
12116                        _ => {}
12117                    }
12118                }
12119                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12120                    match &event.event {
12121                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12122                        ControlEventKind::InputFailed { message, .. } => {
12123                            return Err(failure(
12124                                NodeFailureCode::BackendOperationFailed,
12125                                message,
12126                            ));
12127                        }
12128                        _ => {}
12129                    }
12130                }
12131            }
12132            if Instant::now() >= deadline {
12133                return Err(failure(
12134                    NodeFailureCode::BackendBusy,
12135                    if accepted_after.is_some() {
12136                        "input was accepted but did not settle before the bounded deadline"
12137                    } else {
12138                        "input was not accepted before the bounded deadline"
12139                    },
12140                ));
12141            }
12142            sleep(Duration::from_millis(2)).await;
12143        }
12144    }
12145
12146    async fn dispatch_resize_bounded(
12147        &self,
12148        address: &SessionAddress,
12149        size: gate4agent_types::TerminalSize,
12150    ) -> Result<(), NodeFailure> {
12151        let started_after = self.current_sequence();
12152        let command_id = self
12153            .dispatch_bounded(
12154                ControlCommand::Resize {
12155                    instance_id: address.session.instance_id,
12156                    size,
12157                },
12158                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12159            )
12160            .await?;
12161        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12162        let mut scan_after = started_after;
12163        let mut accepted_after = None;
12164        loop {
12165            let events = self
12166                .history
12167                .lock()
12168                .unwrap_or_else(|poisoned| poisoned.into_inner())
12169                .events
12170                .iter()
12171                .filter(|envelope| envelope.sequence > scan_after)
12172                .cloned()
12173                .collect::<Vec<_>>();
12174            for envelope in events {
12175                scan_after = scan_after.max(envelope.sequence);
12176                let NodeEvent::Control {
12177                    address: event_address,
12178                    event,
12179                } = envelope.event
12180                else {
12181                    continue;
12182                };
12183                if event_address != *address {
12184                    continue;
12185                }
12186                if event.command_id == Some(command_id) {
12187                    match &event.event {
12188                        ControlEventKind::CommandRejected { message } => {
12189                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12190                        }
12191                        ControlEventKind::ResizeRequested {
12192                            size: requested_size,
12193                            ..
12194                        } if *requested_size == size => {
12195                            accepted_after = Some(envelope.sequence);
12196                            continue;
12197                        }
12198                        _ => {}
12199                    }
12200                }
12201                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12202                    match &event.event {
12203                        ControlEventKind::Resized { size: settled_size }
12204                            if *settled_size == size =>
12205                        {
12206                            return Ok(());
12207                        }
12208                        ControlEventKind::ResizeFailed { message } => {
12209                            return Err(failure(
12210                                NodeFailureCode::BackendOperationFailed,
12211                                message,
12212                            ));
12213                        }
12214                        _ => {}
12215                    }
12216                }
12217            }
12218            if Instant::now() >= deadline {
12219                return Err(failure(
12220                    NodeFailureCode::BackendBusy,
12221                    if accepted_after.is_some() {
12222                        "resize was accepted but did not settle before the bounded deadline"
12223                    } else {
12224                        "resize was not accepted before the bounded deadline"
12225                    },
12226                ));
12227            }
12228            sleep(Duration::from_millis(2)).await;
12229        }
12230    }
12231
12232    async fn spawn_session(
12233        &self,
12234        workspace_id: WorkspaceId,
12235        provider: AgentId,
12236        mode: SessionMode,
12237        terminal_size: gate4agent_types::TerminalSize,
12238        initial_prompt: Option<String>,
12239    ) -> Result<SessionAddress, NodeFailure> {
12240        if self.managed_worktrees.lock()
12241            .unwrap_or_else(|poisoned| poisoned.into_inner())
12242            .lease_for_workspace(&workspace_id)
12243            .is_some()
12244        {
12245            return Err(failure(
12246                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12247                "managed worktree targets may only be spawned through their lease request",
12248            ));
12249        }
12250        self
12251            .spawn_session_with_deadline(
12252                workspace_id,
12253                provider,
12254                mode,
12255                terminal_size,
12256                initial_prompt,
12257                None,
12258                None,
12259                None,
12260                None,
12261                None,
12262                SpawnRecordPolicy::ProviderIdentityOnly,
12263                None,
12264                &[],
12265                None,
12266                // `NodeRequest::Spawn` (this method's only caller) carries no
12267                // approval-level selection of its own -- see
12268                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12269                // the one operator-wire path that can choose something other
12270                // than the axis default.
12271                ApprovalLevel::default(),
12272                None,
12273                None,
12274            )
12275            .await
12276            .map(|(session, _runtime_policy)| session)
12277    }
12278
12279    async fn spawn_session_with_deadline(
12280        &self,
12281        workspace_id: WorkspaceId,
12282        provider: AgentId,
12283        mode: SessionMode,
12284        terminal_size: gate4agent_types::TerminalSize,
12285        initial_prompt: Option<String>,
12286        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12287        bundle: Option<ResolvedBundleReceipt>,
12288        context: Option<ResolvedContextPackReceipt>,
12289        managed_authority: Option<ManagedWorktreeLeaseId>,
12290        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12291        record_policy: SpawnRecordPolicy,
12292        deadline: Option<Instant>,
12293        required_capabilities: &[ProviderRuntimeCapability],
12294        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12295        approval_level: ApprovalLevel,
12296        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12297        // Opaque dig2browser station profile id (ids only). Feature
12298        // `dig2-station-probe`: after probe at resolve, spawn takes an
12299        // exclusive node-local lease; cleared on session end. Never
12300        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12301        // already happened on the receipt path).
12302        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12303    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12304        let reserved_lease = self.managed_worktrees.lock()
12305            .unwrap_or_else(|poisoned| poisoned.into_inner())
12306            .lease_for_workspace(&workspace_id);
12307        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12308            (Some(expected), Some(provided)) if expected == provided => {}
12309            (None, None) => {}
12310            _ => {
12311                return Err(failure(
12312                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12313                    "spawn lacks exact managed worktree lease authority",
12314                ));
12315            }
12316        }
12317        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12318        // bindings) on the same workspace cwd. Parallel path: managed worktree
12319        // spawn so each profile gets its own workspace_id.
12320        self.ensure_workspace_environment_profile_compatible(
12321            &workspace_id,
12322            environment_profile.as_ref(),
12323        )?;
12324        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12325            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12326            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12327            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12328            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12329        };
12330        // This session's own transport -- computed once, here, from `mode`,
12331        // and reused both to admit spawn (below) and, further down, to
12332        // compute the final `runtime_policy` this session actually operates
12333        // under (`provider_runtime::policy_for_transport`). Registration
12334        // reuses this exact value rather than recomputing it a second time.
12335        let transport = match mode {
12336            SessionMode::Pty => TransportKind::Pty,
12337            SessionMode::Inline => TransportKind::Pipe,
12338            SessionMode::Acp => TransportKind::Acp,
12339        };
12340        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12341        let acp_transport = self.provider_supports_acp_transport(&provider);
12342        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12343            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12344                NodeFailureCode::UnsupportedSpawnCapability,
12345                "pre-admitted provider runtime no longer satisfies spawn requirements",
12346            ))?;
12347            runtime_policy
12348        } else if let Some(deadline) = deadline {
12349            let remaining = spawn_deadline_remaining(deadline)?;
12350            timeout(
12351                remaining,
12352                self.admit_provider_runtime(&provider, runtime_requirement),
12353            )
12354            .await
12355            .map_err(|_| {
12356                failure(
12357                    NodeFailureCode::SpawnDeadlineExceeded,
12358                    "provider admission exceeded the spawn deadline",
12359                )
12360            })??
12361        } else {
12362            self.admit_provider_runtime(&provider, runtime_requirement).await?
12363        };
12364        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12365        // The single point where this session's `runtime_policy` becomes
12366        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12367        // branch above) only ever compute a PTY-terminal-verification-shaped
12368        // value, sound outright only for `TransportKind::Pty` (Pipe still
12369        // relies on that same value downstream -- see `policy_for_transport`'s
12370        // own doc for why). `policy_for_transport` is the explicit,
12371        // exhaustive match Item 1 asks for -- this is the ONE place its
12372        // output becomes the session's `runtime_policy`, so every later
12373        // reader (`ControlCommand::Start`, `session_bindings`, and every
12374        // engine gate that reads `state.runtime_policy`) sees the same,
12375        // correctly-computed value.
12376        let runtime_policy =
12377            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12378        if required_capabilities
12379            .iter()
12380            .any(|capability| !runtime_policy.admits(*capability))
12381        {
12382            return Err(failure(
12383                NodeFailureCode::UnsupportedSpawnCapability,
12384                "provider runtime does not admit a required spawn capability",
12385            ));
12386        }
12387        if let Some(deadline) = deadline {
12388            spawn_deadline_remaining(deadline)?;
12389        }
12390        self.ensure_binding_capacity()?;
12391        let working_directory = self.workspace_root(&workspace_id)?;
12392        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12393        let session = SessionKey {
12394            instance_id,
12395            generation: SessionGeneration(1),
12396        };
12397        let address = SessionAddress {
12398            workspace_id: workspace_id.clone(),
12399            session,
12400        };
12401        // This is the request-accepted checkpoint: admission (managed
12402        // worktree authority, runtime capability, dispatch deadline) already
12403        // passed and a concrete session identity is minted. It is a distinct
12404        // state from the process actually running (`ControlEventKind::Running`,
12405        // logged in `publish_control`) or the terminal outcome (`Exited`/
12406        // `Failed`, logged there too) -- this line exists so that gap is no
12407        // longer silent.
12408        tracing::info!(
12409            node_id = %self.node_id,
12410            workspace_id = %workspace_id,
12411            provider = %provider,
12412            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12413            session = ?address.session,
12414            "session spawn request accepted",
12415        );
12416        let mut prepared_spawn = SpawnPreparer::prepare(
12417            self,
12418            SpawnPrepareRequest {
12419                instance_id,
12420                address: &address,
12421                provider: &provider,
12422                mode,
12423                environment_profile: environment_profile.as_ref(),
12424                bundle: bundle.as_ref(),
12425                context: context.as_ref(),
12426                managed_authority: managed_authority.as_ref(),
12427                harness_mcp,
12428                approval_level,
12429                network_allowlist: network_allowlist.as_ref(),
12430                browser_profile_id,
12431            },
12432        )?;
12433        let materialization_id = prepared_spawn.materialization_id.take();
12434        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12435        let record_id = self.bind_spawn_session_with_materialization(
12436            &address,
12437            provider.clone(),
12438            mode,
12439            runtime_policy,
12440            record_policy,
12441            environment_profile,
12442            bundle,
12443            context,
12444            materialization_id,
12445        )?;
12446        prepared_spawn.retain_session_guards();
12447        // `transport` was already computed above, alongside this session's
12448        // `runtime_policy`; reused here for `Register` rather than derived a
12449        // second time.
12450        let agent_id = provider;
12451        let register_command_id = match self.dispatch_bounded(
12452            ControlCommand::Register {
12453                instance_id,
12454                agent_id,
12455                transport,
12456            },
12457            dispatch_timeout,
12458        )
12459        .await {
12460            Ok(command_id) => command_id,
12461            Err(error) => {
12462                if let Some(binding) = self.remove_binding(&address) {
12463                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12464                }
12465                if let Some(record_id) = record_id.as_ref() {
12466                    self.discard_record(record_id)?;
12467                }
12468                return Err(spawn_dispatch_error(error, deadline));
12469            }
12470        };
12471        let start_timeout = match spawn_dispatch_timeout(deadline) {
12472            Ok(timeout) => timeout,
12473            Err(error) => {
12474                let recovery = self
12475                    .rollback_spawn(
12476                        &address,
12477                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12478                    )
12479                    .await;
12480                return match recovery {
12481                    Ok(()) => {
12482                        if let Some(record_id) = record_id.as_ref() {
12483                            self.discard_record(record_id)?;
12484                        }
12485                        Err(error)
12486                    }
12487                    Err(recovery_error) => {
12488                        if let Some(record_id) = record_id.as_ref() {
12489                            self.mark_record_error(
12490                                record_id,
12491                                "spawn deadline elapsed and registration recovery failed",
12492                            )?;
12493                        }
12494                        Err(recovery_error)
12495                    }
12496                };
12497            }
12498        };
12499        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12500            if let Err(recovery_error) = self
12501                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12502                .await
12503            {
12504                return Err(recovery_error);
12505            }
12506            if let Some(record_id) = record_id.as_ref() {
12507                self.discard_record(record_id)?;
12508            }
12509            return Err(start_error);
12510        }
12511        let start_result = self
12512            .dispatch_bounded(
12513                ControlCommand::Start {
12514                    instance_id,
12515                    runtime_policy,
12516                    request: StartRequest {
12517                        working_directory,
12518                        terminal_size,
12519                        initial_prompt,
12520                        session_options: None,
12521                        approval_level,
12522                    },
12523                },
12524                start_timeout,
12525            )
12526            .await;
12527        let start_command_id = match start_result {
12528            Ok(command_id) => command_id,
12529            Err(start_error) => {
12530                let start_error = spawn_dispatch_error(start_error, deadline);
12531                let recovery = self
12532                    .rollback_spawn(
12533                        &address,
12534                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12535                    )
12536                    .await;
12537                return match recovery {
12538                    Ok(()) => {
12539                        if let Some(record_id) = record_id.as_ref() {
12540                            self.discard_record(record_id)?;
12541                        }
12542                        Err(start_error)
12543                    }
12544                    Err(recovery_error) => {
12545                        let diagnostic = format!(
12546                            "start failed and registration recovery failed: {}",
12547                            recovery_error.message,
12548                        );
12549                        if let Some(record_id) = record_id.as_ref() {
12550                            self.mark_record_error(record_id, &diagnostic)?;
12551                        }
12552                        Err(failure(recovery_error.code, &diagnostic))
12553                    }
12554                };
12555            }
12556        };
12557
12558        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12559        loop {
12560            // `Register`/`Start` only fail synchronously for a port-level
12561            // ingress problem (see `dispatch_bounded`); the kernel refusing
12562            // either command's content -- an unsupported transport, an
12563            // unknown agent -- lands one drive-loop tick later, and no
12564            // session ever appears in `self.handle.snapshot()` for it to
12565            // notice below. Check for that named reason before falling
12566            // through to the blind presence poll, so a rejected spawn
12567            // reports why instead of just timing out.
12568            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12569                .or_else(|| self.take_rejected_command_reason(start_command_id))
12570            {
12571                self.rollback_spawn(
12572                    &address,
12573                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12574                ).await.map_err(|error| {
12575                        failure(
12576                            error.code,
12577                            &format!(
12578                                "spawn was rejected ({}) and registration recovery failed: {}",
12579                                reason.message, error.message
12580                            ),
12581                        )
12582                    })?;
12583                if let Some(record_id) = record_id.as_ref() {
12584                    self.discard_record(record_id)?;
12585                }
12586                let code = if reason.unsupported_transport {
12587                    NodeFailureCode::UnsupportedTransport
12588                } else {
12589                    NodeFailureCode::BackendOperationFailed
12590                };
12591                return Err(failure(code, &reason.message));
12592            }
12593            if let Some(current) = self
12594                .handle
12595                .snapshot()
12596                .sessions
12597                .iter()
12598                .find(|current| current.instance_id == instance_id)
12599            {
12600                if current.generation != session.generation {
12601                    let divergence = failure(
12602                        NodeFailureCode::StaleGeneration,
12603                        "spawn generation diverged",
12604                    );
12605                    return match self
12606                        .rollback_spawn(
12607                            &address,
12608                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12609                        )
12610                        .await
12611                    {
12612                        Ok(()) => {
12613                            if let Some(record_id) = record_id.as_ref() {
12614                                self.discard_record(record_id)?;
12615                            }
12616                            Err(divergence)
12617                        }
12618                        Err(recovery_error) => {
12619                            if let Some(record_id) = record_id.as_ref() {
12620                                self.mark_record_error(
12621                                    record_id,
12622                                    "spawn generation diverged and recovery failed",
12623                                )?;
12624                            }
12625                            Err(recovery_error)
12626                        }
12627                    };
12628                }
12629                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12630                    prepared_spawn.retain_launch_guards();
12631                    return Ok((address, runtime_policy));
12632                }
12633            }
12634            if Instant::now() >= commit_deadline {
12635                self.rollback_spawn(
12636                    &address,
12637                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12638                ).await.map_err(|error| {
12639                        failure(
12640                            error.code,
12641                            &format!(
12642                                "start did not commit and registration recovery failed: {}",
12643                                error.message
12644                            ),
12645                        )
12646                    })?;
12647                if let Some(record_id) = record_id.as_ref() {
12648                    self.discard_record(record_id)?;
12649                }
12650                return Err(failure(
12651                    if deadline.is_some() {
12652                        NodeFailureCode::SpawnDeadlineExceeded
12653                    } else {
12654                        NodeFailureCode::BackendBusy
12655                    },
12656                    "start did not commit before the bounded deadline; registration was removed",
12657                ));
12658            }
12659            sleep(Duration::from_millis(2)).await;
12660        }
12661    }
12662
12663    fn effective_spawn_runtime_policy(
12664        &self,
12665        runtime_policy: ProviderRuntimePolicy,
12666    ) -> ProviderRuntimePolicy {
12667        #[cfg(feature = "fixture")]
12668        let runtime_policy = if self.fixture_semantic_hook_policy {
12669            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12670                .expect("monitoring Hook fixture policy is internally valid")
12671        } else {
12672            runtime_policy
12673        };
12674        runtime_policy
12675    }
12676
12677    fn verify_harness_mcp_before_start(
12678        &self,
12679        prepared: Option<&PreparedHarnessMcpSpawn>,
12680    ) -> Result<(), NodeFailure> {
12681        prepared
12682            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12683            .transpose()
12684            .map(|_| ())
12685            .map_err(harness_mcp_failure)
12686    }
12687
12688    async fn rollback_spawn(
12689        &self,
12690        address: &SessionAddress,
12691        commit_timeout: Duration,
12692    ) -> Result<(), NodeFailure> {
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        if let Some(binding) = self.remove_binding(address) {
12702            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12703        }
12704        Ok(())
12705    }
12706
12707    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12708        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12709        self.dispatch_bounded(
12710            ControlCommand::Remove {
12711                instance_id: address.session.instance_id,
12712            },
12713            commit_timeout,
12714        )
12715            .await?;
12716        self.wait_until_removed(address, commit_timeout).await?;
12717        let removed = self.remove_binding(address);
12718        if let Some(binding) = removed.as_ref() {
12719            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12720        }
12721        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12722            let snapshot = self.managed_worktrees.lock()
12723                .unwrap_or_else(|poisoned| poisoned.into_inner())
12724                .release_session(
12725                    &lease_id,
12726                    self.incarnation_id,
12727                    address.session.instance_id,
12728                    address.session.generation,
12729                    unix_time_ms(),
12730                );
12731            if self.persist_state().is_err() {
12732                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12733            }
12734            if let Some(snapshot) = snapshot {
12735                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12736            }
12737            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12738        }
12739        Ok(())
12740    }
12741
12742    /// Polls until `address`'s session leaves the engine's own session map,
12743    /// or the bounded deadline expires.
12744    ///
12745    /// A `Remove` sent while the session is not yet in a terminal status
12746    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12747    /// once by the engine (`SessionStatus::allows_remove`) and
12748    /// then discarded -- nothing re-issues it on its own, so the removal
12749    /// that would satisfy this wait never arrives and the caller was
12750    /// guaranteed to time out even though the session settles microseconds
12751    /// later. So this re-dispatches on every poll tick: a session that
12752    /// settles mid-wait -- the common case right after a `Stop` -- still
12753    /// gets removed inside the same bounded deadline.
12754    ///
12755    /// It re-dispatches only once the status will ACCEPT the command. A
12756    /// rejected re-dispatch is not the harmless no-op this loop first
12757    /// assumed: every rejection publishes a `ControlEventKind::Command
12758    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12759    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12760    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12761    /// out to the harness and every attached client. Gated, the same
12762    /// teardown sends exactly one `Remove`, at the first tick the engine
12763    /// can act on it.
12764    ///
12765    /// The status is read from the snapshot this loop already takes, so
12766    /// the gate costs nothing extra. It races -- the status can move
12767    /// between the read and the dispatch -- and that is fine: a rare lost
12768    /// race is one rejection, and the next tick retries.
12769    ///
12770    /// Re-dispatch is separately rate-limited to
12771    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12772    /// leaves a second burst behind it. Once the session settles, every
12773    /// 2ms tick sent another `Remove` while the accepted one was still in
12774    /// flight, and each of those landed on an instance the engine had
12775    /// already dropped -- `agent instance … is not registered`, observed
12776    /// live as a tail of rejections immediately after the `session
12777    /// removed` line. The presence poll stays at 2ms so the wait still
12778    /// exits promptly; only the sending slows down.
12779    ///
12780    /// Rate-limited rather than once-only on purpose: the status gate
12781    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12782    /// others (a pending operation, an in-flight capability probe or
12783    /// history request) that clear on their own. A single shot would turn
12784    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12785    /// get their retries, just twenty of them across the deadline instead
12786    /// of a thousand.
12787    async fn wait_until_removed(
12788        &self,
12789        address: &SessionAddress,
12790        commit_timeout: Duration,
12791    ) -> Result<(), NodeFailure> {
12792        let deadline = Instant::now() + commit_timeout;
12793        let mut next_dispatch = Instant::now();
12794        loop {
12795            let snapshot = self.handle.snapshot();
12796            let status = snapshot
12797                .sessions
12798                .iter()
12799                .find(|session| session.instance_id == address.session.instance_id)
12800                .map(|session| session.status.clone());
12801            let Some(status) = status else {
12802                return Ok(());
12803            };
12804            let now = Instant::now();
12805            if now >= deadline {
12806                return Err(failure(
12807                    NodeFailureCode::BackendBusy,
12808                    "remove did not commit before the bounded deadline; session binding was retained",
12809                ));
12810            }
12811            if status.allows_remove() && now >= next_dispatch {
12812                let _ = self.dispatch(ControlCommand::Remove {
12813                    instance_id: address.session.instance_id,
12814                });
12815                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12816            }
12817            sleep(Duration::from_millis(2)).await;
12818        }
12819    }
12820
12821    fn resync(&self, after_sequence: u64) -> NodeResponse {
12822        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12823        let event_sequence = history.last_sequence;
12824        let oldest_available_sequence = history.replay_floor_sequence;
12825        let events = history
12826            .events
12827            .iter()
12828            .filter(|event| event.sequence > after_sequence)
12829            .cloned()
12830            .collect();
12831        drop(history);
12832        NodeResponse::Resync {
12833            event_sequence,
12834            oldest_available_sequence,
12835            snapshot: self.snapshot(),
12836            events,
12837        }
12838    }
12839}
12840
12841/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12842/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12843/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12844/// "name what crossed this boundary" reasoning, one layer upstream of it).
12845fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12846    match kind {
12847        AgentStreamChunkKindV1::Text { .. } => "text",
12848        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12849        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12850        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12851        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12852        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12853        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12854    }
12855}
12856
12857/// Serves this wire on the local endpoint, and -- when `call_home` names a
12858/// relay -- over an outbound connection to it as well.
12859///
12860/// The two live in one future rather than a fourth arm of `run`'s own
12861/// `select!` because they are one concern: this is where the node's wire
12862/// is served, and the direction a given connection was opened in does not
12863/// change what happens on it. It also means the existing graceful drain
12864/// below covers both without being duplicated.
12865///
12866/// Either side finishing ends the pair, which is correct: the listener
12867/// only returns on shutdown or a bind failure, and `call_home_loop` only
12868/// returns on shutdown -- a failed relay connection is retried inside it,
12869/// never propagated out.
12870async fn accept_connections(
12871    endpoint: &str,
12872    call_home: Option<std::net::SocketAddr>,
12873    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12874    shared: Arc<NodeShared>,
12875) -> Result<(), NodeServerError> {
12876    let mut connections = JoinSet::new();
12877    let result = match call_home {
12878        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12879        Some(relay) => {
12880            let listener =
12881                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12882            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12883            tokio::pin!(listener);
12884            tokio::pin!(dialler);
12885            tokio::select! {
12886                result = &mut listener => result,
12887                result = &mut dialler => result,
12888            }
12889        }
12890    };
12891    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12892    while !connections.is_empty() {
12893        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12894        if remaining.is_zero()
12895            || timeout(remaining, connections.join_next()).await.is_err()
12896        {
12897            break;
12898        }
12899    }
12900    connections.abort_all();
12901    while connections.join_next().await.is_some() {}
12902    result
12903}
12904
12905/// Backoff ladder for a relay that will not answer, capped rather than
12906/// unbounded: a node whose relay is down is not broken and should keep
12907/// trying forever, but it should not spend a core doing it.
12908const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12909
12910/// Dials the relay, serves this wire over the connection, and does it
12911/// again when the connection ends.
12912///
12913/// The node is the SERVER on the socket it just opened. That inversion is
12914/// the whole point: `serve_connection` is the same function the local
12915/// listener feeds, and it neither knows nor cares which end placed the
12916/// call. The one thing this direction adds is the preface -- the relay
12917/// accepted a socket and cannot know whose it is, so the node says its
12918/// name before the handshake it would otherwise begin with.
12919///
12920/// Never returns an error. A relay that is down, refusing, or speaking a
12921/// different protocol is a condition to retry, not a reason to take the
12922/// node's local endpoint down with it; the only way out is shutdown.
12923async fn call_home_loop(
12924    relay: std::net::SocketAddr,
12925    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12926    shared: Arc<NodeShared>,
12927) -> Result<(), NodeServerError> {
12928    let mut failures = 0_usize;
12929    loop {
12930        if shared.shutdown.load(Ordering::Acquire) {
12931            return Ok(());
12932        }
12933        let permit = tokio::select! {
12934            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12935                permit.map_err(|_| NodeServerError::PreauthClosed)?
12936            }
12937            _ = shared.shutdown_notify.notified() => return Ok(()),
12938        };
12939        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
12940            Ok(()) => {
12941                // A clean end is the relay hanging up, which it does on
12942                // its own restart. Reconnect immediately rather than
12943                // serving the backoff a peer that is plainly there.
12944                failures = 0;
12945                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12946            }
12947            Err(error) => {
12948                failures = failures.saturating_add(1);
12949                tracing::warn!(
12950                    node_id = %shared.node_id,
12951                    %relay,
12952                    cause = %error,
12953                    attempt = failures,
12954                    "call-home connection failed",
12955                );
12956                let backoff = CALL_HOME_BACKOFFS_MS
12957                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12958                tokio::select! {
12959                    _ = sleep(Duration::from_millis(backoff)) => {}
12960                    _ = shared.shutdown_notify.notified() => return Ok(()),
12961                }
12962            }
12963        }
12964    }
12965}
12966
12967/// One call-home attempt: connect, name this node, then serve.
12968async fn dial_relay_and_serve(
12969    relay: std::net::SocketAddr,
12970    identity: Option<&hatchery_enroll::NodeIdentity>,
12971    shared: Arc<NodeShared>,
12972    preauth_permit: OwnedSemaphorePermit,
12973) -> Result<(), NodeServerError> {
12974    let mut stream = tokio::net::TcpStream::connect(relay)
12975        .await
12976        .map_err(NodeServerError::CallHomeTransport)?;
12977    // Nagle off for the same reason the rest of this wire wants it off:
12978    // the traffic is small request/response frames, and coalescing them
12979    // adds latency to every one.
12980    let _ = stream.set_nodelay(true);
12981    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12982        .await
12983        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12984    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12985    let mut session_token = None;
12986    if let Some(identity) = identity {
12987        // An enrolled relay answers the announce with a signed-call-home challenge; a
12988        // token-mode relay sends its wire hello instead. Peek to tell them apart
12989        // without consuming either.
12990        let mut magic = [0_u8; 4];
12991        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
12992            .await
12993            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
12994            .map_err(NodeServerError::CallHomeTransport)?;
12995        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
12996            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
12997            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
12998                .await
12999                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13000                .map_err(NodeServerError::CallHomeTransport)?;
13001            let (answer, token) = identity
13002                .answer_challenge(shared.node_id.as_str(), &frame)
13003                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13004            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13005            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13006            session_token = Some(token);
13007        }
13008    }
13009    serve_connection(stream, shared, session_token, preauth_permit).await
13010}
13011
13012async fn accept_connections_inner(
13013    endpoint: &str,
13014    shared: Arc<NodeShared>,
13015    connections: &mut JoinSet<Result<(), NodeServerError>>,
13016) -> Result<(), NodeServerError> {
13017    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13018    loop {
13019        while connections.try_join_next().is_some() {}
13020        if shared.shutdown.load(Ordering::Acquire) {
13021            return Ok(());
13022        }
13023        let preauth_permit = tokio::select! {
13024            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13025                permit.map_err(|_| NodeServerError::PreauthClosed)?
13026            }
13027            _ = shared.shutdown_notify.notified() => return Ok(()),
13028        };
13029        let server = tokio::select! {
13030            result = listener.accept() => result?,
13031            _ = shared.shutdown_notify.notified() => return Ok(()),
13032        };
13033        let connection_shared = Arc::clone(&shared);
13034        connections.spawn(async move {
13035            serve_connection(server, connection_shared, None, preauth_permit).await
13036        });
13037        if shared.shutdown.load(Ordering::Acquire) {
13038            return Ok(());
13039        }
13040    }
13041}
13042
13043/// Serves one connection, whichever end opened it.
13044///
13045/// Generic over the stream rather than typed to `LocalServerStream`
13046/// because a call-home connection arrives on a `TcpStream` the node itself
13047/// dialled. Nothing in the body ever depended on the concrete type -- it
13048/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13049/// the whole of what welding "who dials" to "who serves" was costing.
13050async fn serve_connection<S>(
13051    mut pipe: S,
13052    shared: Arc<NodeShared>,
13053    session_token: Option<String>,
13054    preauth_permit: OwnedSemaphorePermit,
13055) -> Result<(), NodeServerError>
13056where
13057    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13058{
13059    // A signed call-home connection authenticates with its own per-connection token.
13060    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13061    let hello_frame = timeout(
13062        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13063        read_json_frame_limited_body_timeout(
13064            &mut pipe,
13065            MAX_NODE_HELLO_FRAME_BYTES,
13066            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13067        ),
13068    )
13069    .await
13070    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13071    let ClientFrame::Hello(hello) = hello_frame else {
13072        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13073    };
13074    if hello.build_stamp != BUILD_STAMP {
13075        return Err(NodeServerError::Handshake(format!(
13076            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13077            hello.build_stamp,
13078        )));
13079    }
13080    let compatibility = match hello.compatibility.as_ref() {
13081        Some(offer) => {
13082            let mut selected = node_compatibility_support(&shared)?
13083                .negotiate(offer)
13084                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13085            project_negotiated_provider_ids(&mut selected);
13086            Some(selected)
13087        }
13088        None => None,
13089    };
13090    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13091    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13092        (Some(offer), Some(selected)) => negotiated_auth_proof(
13093            auth_token,
13094            AuthDirection::Server,
13095            hello.role,
13096            &hello.client_nonce,
13097            &server_nonce,
13098            offer,
13099            selected,
13100        ),
13101        (None, None) => auth_proof(
13102            auth_token,
13103            AuthDirection::Server,
13104            hello.role,
13105            &hello.client_nonce,
13106            &server_nonce,
13107        ),
13108        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13109    }
13110    .map_err(NodeServerError::Authentication)?;
13111    write_json_frame_limited(
13112        &mut pipe,
13113        &ServerFrame::Challenge(ServerChallenge {
13114            build_stamp: BUILD_STAMP.to_owned(),
13115            server_nonce,
13116            server_proof,
13117            compatibility: compatibility.clone(),
13118        }),
13119        MAX_NODE_HELLO_FRAME_BYTES,
13120    )
13121    .await?;
13122    let authentication = timeout(
13123        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13124        read_json_frame_limited_body_timeout(
13125            &mut pipe,
13126            MAX_NODE_HELLO_FRAME_BYTES,
13127            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13128        ),
13129    )
13130    .await
13131    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13132    let ClientFrame::Authenticate(authentication) = authentication else {
13133        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13134    };
13135    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13136        (Some(offer), Some(selected)) => negotiated_auth_proof(
13137            auth_token,
13138            AuthDirection::Client,
13139            hello.role,
13140            &hello.client_nonce,
13141            &server_nonce,
13142            offer,
13143            selected,
13144        ),
13145        (None, None) => auth_proof(
13146            auth_token,
13147            AuthDirection::Client,
13148            hello.role,
13149            &hello.client_nonce,
13150            &server_nonce,
13151        ),
13152        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13153    }
13154    .map_err(NodeServerError::Authentication)?;
13155    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13156        return Err(NodeServerError::Handshake("access denied".to_owned()));
13157    }
13158    let selected_capabilities = compatibility
13159        .as_ref()
13160        .map(|selected| selected.capabilities.clone())
13161        .unwrap_or_default();
13162    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13163        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13164    });
13165    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13166        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13167    });
13168    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13169        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13170    });
13171    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13172        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13173    });
13174    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13175        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13176    });
13177    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13178        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13179    }) && selected_capabilities.iter().any(|capability| {
13180        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13181    });
13182    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13183        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13184    });
13185    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13186        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13187    });
13188    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13189        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13190    });
13191    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13192        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13193    });
13194    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13195        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13196    });
13197    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13198        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13199    });
13200    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13201        .try_acquire_owned()
13202        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13203    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13204    let _release_guard = ControllerReleaseGuard {
13205        shared: Arc::clone(&shared),
13206        connection_id,
13207    };
13208    drop(preauth_permit);
13209    let _authenticated_permit = authenticated_permit;
13210    let mut event_rx = shared.event_tx.subscribe();
13211    let mut terminal_event_rx = terminal_event_subscription(
13212        &shared,
13213        include_terminal_frame_events,
13214    );
13215    let mut agent_stream_event_rx = agent_stream_event_subscription(
13216        &shared,
13217        include_agent_stream_events,
13218    );
13219    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13220        &shared,
13221        include_harness_mcp_proxy,
13222    );
13223    write_json_frame(
13224        &mut pipe,
13225        &ServerFrame::Hello(NodeHello {
13226            build_stamp: BUILD_STAMP.to_owned(),
13227            incarnation_id: shared.incarnation_id,
13228            connection_id,
13229            role: hello.role,
13230            event_sequence: shared.current_sequence(),
13231            controller: shared.controller_state(),
13232            snapshot: snapshot_for_wire(
13233                &shared,
13234                include_provider_runtime_status,
13235                include_open_provider_ids,
13236                include_spawn_profiles,
13237                include_managed_worktrees,
13238                include_child_environment_profiles,
13239                include_session_bundles,
13240                include_history_context_packs,
13241                include_agent_progress,
13242                include_session_task_correlation,
13243            ),
13244            compatibility,
13245        }),
13246    )
13247    .await?;
13248    let (mut reader, mut writer) = tokio::io::split(pipe);
13249    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13250    let reader_task = tokio::spawn(async move {
13251        loop {
13252            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13253                &mut reader,
13254                MAX_NODE_CLIENT_FRAME_BYTES,
13255                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13256            )
13257            .await;
13258            let terminal = frame.is_err();
13259            if frame_tx.send(frame).await.is_err() || terminal {
13260                break;
13261            }
13262        }
13263    });
13264    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13265    let mut pending_events = Vec::new();
13266    let mut resync_required = false;
13267    let mut discard_events_through = 0_u64;
13268    loop {
13269        // One tick of the serve loop, whether or not it had anything to do.
13270        // Against `connection_events_sent` this separates "busy" from
13271        // "spinning": a loop turning over thousands of times a second while
13272        // sending nothing is not serving, it is burning a core.
13273        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13274        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13275            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13276        for _ in 0..durable_drain_budget {
13277            match event_rx.try_recv() {
13278                Ok(event) => queue_connection_event(
13279                    &mut pending_events,
13280                    event,
13281                    discard_events_through,
13282                ),
13283                Err(broadcast::error::TryRecvError::Empty) => break,
13284                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13285                    resync_required = true;
13286                    break;
13287                }
13288                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13289            }
13290        }
13291        if let Some(receiver) = terminal_event_rx.as_mut() {
13292            match receiver.try_recv() {
13293                Ok(events) => queue_connection_event_batch(
13294                    &mut pending_events,
13295                    &events,
13296                    discard_events_through,
13297                ),
13298                Err(broadcast::error::TryRecvError::Empty) => {}
13299                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13300                    resync_required = true;
13301                }
13302                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13303            }
13304        }
13305        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13306            // Loops up to the same remaining-room budget the durable
13307            // `event_rx` drain above uses, rather than one `try_recv` per
13308            // tick: each successful receive here is itself only ONE
13309            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13310            // sends `vec![envelope]`, never more), so a single-shot drain
13311            // caps this connection's agent-stream throughput at one chunk
13312            // per outer-loop tick regardless of how many are already queued
13313            // -- exactly the kind of self-inflicted stall this crate's own
13314            // durable-event budget exists to avoid.
13315            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13316                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13317            for _ in 0..agent_stream_drain_budget {
13318                match receiver.try_recv() {
13319                    Ok(events) => queue_connection_event_batch(
13320                        &mut pending_events,
13321                        &events,
13322                        discard_events_through,
13323                    ),
13324                    Err(broadcast::error::TryRecvError::Empty) => break,
13325                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13326                        tracing::warn!(
13327                            connection_id,
13328                            dropped,
13329                            "agent stream event subscription lagged; forcing a resync",
13330                        );
13331                        resync_required = true;
13332                        break;
13333                    }
13334                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13335                }
13336            }
13337        }
13338        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13339            // Mirrors the agent-stream drain immediately above: each
13340            // successful receive is one single-envelope batch
13341            // (`publish_transient` always sends `vec![envelope]`), so this
13342            // loops up to the same remaining-room budget rather than
13343            // stopping after one `try_recv`.
13344            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13345                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13346            for _ in 0..harness_mcp_proxy_drain_budget {
13347                match receiver.try_recv() {
13348                    Ok(events) => queue_connection_event_batch(
13349                        &mut pending_events,
13350                        &events,
13351                        discard_events_through,
13352                    ),
13353                    Err(broadcast::error::TryRecvError::Empty) => break,
13354                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13355                        tracing::warn!(
13356                            connection_id,
13357                            dropped,
13358                            "harness MCP proxy event subscription lagged; forcing a resync",
13359                        );
13360                        resync_required = true;
13361                        break;
13362                    }
13363                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13364                }
13365            }
13366        }
13367        if resync_required {
13368            let event = resync_required_event(&shared);
13369            apply_connection_resync_watermark(
13370                &mut pending_events,
13371                &mut discard_events_through,
13372                event.sequence,
13373            );
13374            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13375            resync_required = false;
13376            continue;
13377        }
13378        if !pending_events.is_empty() {
13379            pending_events.sort_unstable_by_key(|event| event.sequence);
13380            let burst_len = connection_event_burst_len(pending_events.len());
13381            shared
13382                .connection_events_sent
13383                .fetch_add(burst_len as u64, Ordering::Relaxed);
13384            for event in pending_events.drain(..burst_len) {
13385                let event = if include_managed_worktrees {
13386                    Some(event)
13387                } else {
13388                    project_event_without_managed_worktrees(event)
13389                };
13390                let event = event.and_then(|event| if include_open_provider_ids {
13391                    Some(event)
13392                } else {
13393                    project_event_legacy_provider_ids(&shared, event)
13394                });
13395                let event = event.map(|event| if include_child_environment_profiles {
13396                    event
13397                } else {
13398                    project_event_without_child_environment_profile(event)
13399                });
13400                let event = event.map(|event| if include_session_bundles {
13401                    event
13402                } else {
13403                    project_event_without_session_bundle(event)
13404                });
13405                let event = event.map(|event| {
13406                    project_event_history_for_wire(event, include_history_context_packs)
13407                });
13408                let event = event.map(|event| if include_history_context_packs {
13409                    event
13410                } else {
13411                    project_event_without_context_pack(event)
13412                });
13413                let event = event.map(|event| if include_session_task_correlation {
13414                    event
13415                } else {
13416                    project_event_without_session_task_binding(event)
13417                });
13418                let exact_harness_controller = hello.role == ClientRole::Operator
13419                    && shared.controller_state().is_some_and(|controller| {
13420                        controller.connection_id == connection_id
13421                    });
13422                let event = event.and_then(|event| {
13423                    if include_harness_mcp_proxy && exact_harness_controller {
13424                        Some(event)
13425                    } else {
13426                        project_event_without_harness_mcp_proxy(event)
13427                    }
13428                });
13429                if let Some(event) = event {
13430                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13431                }
13432            }
13433        }
13434        tokio::select! {
13435            biased;
13436            frame = frame_rx.recv() => {
13437                let frame = match frame {
13438                    Some(frame) => frame,
13439                    None => break,
13440                };
13441                let frame = match frame {
13442                    Ok(frame) => frame,
13443                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13444                    Err(error) => return Err(error.into()),
13445                };
13446                let ClientFrame::Request(request) = frame else {
13447                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13448                };
13449                #[cfg(feature = "fixture")]
13450                let probe_spawn_managed_worktree_v2 = matches!(
13451                    &request.request,
13452                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13453                );
13454                let requires_open_provider_ids =
13455                    request_requires_open_provider_ids(&shared, &request.request);
13456                let requires_child_environment_profile =
13457                    request_requires_child_environment_profile(&shared, &request.request);
13458                let requires_session_bundle =
13459                    request_requires_session_bundle(&shared, &request.request);
13460                let requires_history_context_pack =
13461                    request_requires_history_context_pack(&shared, &request.request);
13462                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13463                    ResponseEnvelope {
13464                        request_id: request.request_id,
13465                        result: Err(failure(
13466                            NodeFailureCode::UnsupportedCapability,
13467                            "open provider IDs were not negotiated",
13468                        )),
13469                    }
13470                } else if requires_child_environment_profile
13471                    && !include_child_environment_profiles
13472                {
13473                    ResponseEnvelope {
13474                        request_id: request.request_id,
13475                        result: Err(failure(
13476                            NodeFailureCode::UnsupportedCapability,
13477                            "child environment profiles were not negotiated",
13478                        )),
13479                    }
13480                } else if requires_session_bundle && !include_session_bundles {
13481                    ResponseEnvelope {
13482                        request_id: request.request_id,
13483                        result: Err(failure(
13484                            NodeFailureCode::UnsupportedCapability,
13485                            "session bundles were not negotiated",
13486                        )),
13487                    }
13488                } else if requires_history_context_pack && !include_history_context_packs {
13489                    ResponseEnvelope {
13490                        request_id: request.request_id,
13491                        result: Err(failure(
13492                            NodeFailureCode::UnsupportedCapability,
13493                            "history context packs were not negotiated",
13494                        )),
13495                    }
13496                } else if request_uses_unnegotiated_capability(
13497                    &request.request,
13498                    &selected_capabilities,
13499                )
13500                {
13501                    ResponseEnvelope {
13502                        request_id: request.request_id,
13503                        result: Err(failure(
13504                            NodeFailureCode::UnsupportedCapability,
13505                            "request capability was not negotiated",
13506                        )),
13507                    }
13508                } else {
13509                    process_request(&shared, connection_id, hello.role, request).await
13510                };
13511                #[cfg(feature = "fixture")]
13512                if probe_spawn_managed_worktree_v2 {
13513                    shared
13514                        .fixture_spawn_managed_worktree_v2_failure_probe
13515                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13516                }
13517                if !include_provider_runtime_status {
13518                    clear_response_provider_runtime_status(&mut reply);
13519                }
13520                if !include_managed_worktrees {
13521                    project_response_without_managed_worktrees(&mut reply);
13522                }
13523                if !include_open_provider_ids {
13524                    project_response_legacy_provider_ids(&shared, &mut reply);
13525                }
13526                if !include_child_environment_profiles {
13527                    project_response_without_child_environment_profile(&mut reply);
13528                }
13529                if !include_session_bundles {
13530                    project_response_without_session_bundle(&mut reply);
13531                }
13532                project_response_history_for_wire(
13533                    &mut reply,
13534                    include_history_context_packs,
13535                );
13536                if !include_history_context_packs {
13537                    project_response_without_context_pack(&mut reply);
13538                }
13539                if !include_agent_progress {
13540                    project_response_without_agent_progress(&mut reply);
13541                }
13542                if !include_session_task_correlation {
13543                    project_response_without_session_task_binding(&mut reply);
13544                }
13545                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13546            }
13547            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13548            event = event_rx.recv() => {
13549                match event {
13550                    Ok(event) => queue_connection_event(
13551                        &mut pending_events,
13552                        event,
13553                        discard_events_through,
13554                    ),
13555                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13556                    Err(broadcast::error::RecvError::Closed) => break,
13557                }
13558            }
13559            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13560                match events {
13561                    Ok(events) => queue_connection_event_batch(
13562                        &mut pending_events,
13563                        &events,
13564                        discard_events_through,
13565                    ),
13566                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13567                    Err(broadcast::error::RecvError::Closed) => break,
13568                }
13569            }
13570            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13571                match events {
13572                    Ok(events) => queue_connection_event_batch(
13573                        &mut pending_events,
13574                        &events,
13575                        discard_events_through,
13576                    ),
13577                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13578                    Err(broadcast::error::RecvError::Closed) => break,
13579                }
13580            }
13581            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13582                match events {
13583                    Ok(events) => queue_connection_event_batch(
13584                        &mut pending_events,
13585                        &events,
13586                        discard_events_through,
13587                    ),
13588                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13589                    Err(broadcast::error::RecvError::Closed) => break,
13590                }
13591            }
13592            _ = shared.shutdown_notify.notified() => break,
13593        }
13594    }
13595    Ok(())
13596}
13597
13598async fn receive_terminal_event_batch(
13599    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13600) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13601    match receiver {
13602        Some(receiver) => receiver.recv().await,
13603        None => std::future::pending().await,
13604    }
13605}
13606
13607fn terminal_event_subscription(
13608    shared: &NodeShared,
13609    enabled: bool,
13610) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13611    enabled.then(|| shared.terminal_event_tx.subscribe())
13612}
13613
13614/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13615/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13616/// this needs a dedicated channel rather than a flag on the terminal one.
13617fn agent_stream_event_subscription(
13618    shared: &NodeShared,
13619    enabled: bool,
13620) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13621    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13622}
13623
13624/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13625/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13626/// own doc for why this needs a dedicated channel rather than a ride on
13627/// the durable `event_tx`.
13628fn harness_mcp_proxy_event_subscription(
13629    shared: &NodeShared,
13630    enabled: bool,
13631) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13632    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13633}
13634
13635fn queue_connection_event(
13636    pending: &mut Vec<NodeEventEnvelope>,
13637    event: NodeEventEnvelope,
13638    discard_through: u64,
13639) {
13640    if event.sequence <= discard_through
13641        && !event.event.requires_harness_mcp_proxy_capability()
13642    {
13643        return;
13644    }
13645    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13646        if let Some(index) = pending.iter().position(|current| {
13647            matches!(
13648                &current.event,
13649                NodeEvent::TerminalFrame {
13650                    address: current_address,
13651                    ..
13652                } if current_address == address
13653            )
13654        }) {
13655            pending.remove(index);
13656        }
13657    }
13658    pending.push(event);
13659}
13660
13661fn project_event_without_harness_mcp_proxy(
13662    envelope: NodeEventEnvelope,
13663) -> Option<NodeEventEnvelope> {
13664    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13665}
13666
13667fn queue_connection_event_batch(
13668    pending: &mut Vec<NodeEventEnvelope>,
13669    events: &[NodeEventEnvelope],
13670    discard_through: u64,
13671) {
13672    for event in events {
13673        queue_connection_event(pending, event.clone(), discard_through);
13674    }
13675}
13676
13677fn apply_connection_resync_watermark(
13678    pending: &mut Vec<NodeEventEnvelope>,
13679    discard_through: &mut u64,
13680    marker_sequence: u64,
13681) {
13682    *discard_through = (*discard_through).max(marker_sequence);
13683    pending.retain(|event| event.sequence > *discard_through);
13684}
13685
13686fn connection_event_burst_len(pending_len: usize) -> usize {
13687    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13688}
13689
13690fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13691    let history = shared
13692        .history
13693        .lock()
13694        .unwrap_or_else(|poisoned| poisoned.into_inner());
13695    let sequence = history.last_sequence;
13696    let oldest_available_sequence = history.replay_floor_sequence;
13697    NodeEventEnvelope {
13698        sequence,
13699        event: NodeEvent::ResyncRequired {
13700            oldest_available_sequence,
13701        },
13702    }
13703}
13704
13705fn node_compatibility_support(
13706    shared: &NodeShared,
13707) -> Result<NodeCompatibilitySupport, NodeServerError> {
13708    let mut support = node_compatibility_support_for_manifest(
13709        &shared.provider_contracts,
13710        &shared.provider_adapter_contracts,
13711    )?;
13712    if shared
13713        .delivery_store
13714        .lock()
13715        .unwrap_or_else(|poisoned| poisoned.into_inner())
13716        .is_some()
13717    {
13718        support.capabilities.push(
13719            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13720                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13721        );
13722    }
13723    if shared.harness_mcp_registry.is_some() {
13724        support.capabilities.push(
13725            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13726                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13727        );
13728    }
13729    Ok(support)
13730}
13731
13732fn node_compatibility_support_for_manifest(
13733    provider_contracts: &[ProviderContractSupport],
13734    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13735) -> Result<NodeCompatibilitySupport, NodeServerError> {
13736    Ok(NodeCompatibilitySupport {
13737        build_stamp: BUILD_STAMP.to_owned(),
13738        capabilities: baseline_capabilities()?,
13739        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13740        path_semantics: platform::path_semantics(),
13741        local_transport: platform::local_transport(),
13742        state_schema: StateSchemaSupport {
13743            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13744                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13745        },
13746        provider_contracts: provider_contracts.to_vec(),
13747        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13748    })
13749}
13750
13751fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13752    let capabilities = [
13753        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13754        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13755        NODE_REPOSITORY_PATH_CAPABILITY,
13756        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13757        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13758        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13759        NODE_GIT_READ_CAPABILITY,
13760        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13761        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13762        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13763        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13764        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13765        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13766        NODE_ACP_CONTROL_CAPABILITY,
13767        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13768        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13769        NODE_WORKTREE_SELECTION_CAPABILITY,
13770        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13771        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13772        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13773        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13774        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13775        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13776        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13777        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13778        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13779        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13780        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13781        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13782    ]
13783        .into_iter()
13784        .map(|capability| {
13785            CapabilityId::new(capability)
13786                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13787        })
13788        .collect::<Result<Vec<_>, _>>()?;
13789    #[cfg(windows)]
13790    let capabilities = {
13791        let mut capabilities = capabilities;
13792        capabilities.push(
13793            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13794                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13795        );
13796        capabilities
13797    };
13798    Ok(capabilities)
13799}
13800
13801fn request_uses_unnegotiated_capability(
13802    request: &NodeRequest,
13803    selected_capabilities: &[CapabilityId],
13804) -> bool {
13805    let selected = |required: &str| {
13806        selected_capabilities
13807            .iter()
13808            .any(|capability| capability.as_str() == required)
13809    };
13810    request
13811        .required_capability()
13812        .is_some_and(|required| !selected(required))
13813        || (request.requires_worktree_selection_capability()
13814            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13815        || (request.requires_spawn_spec_defaults_overrides_capability()
13816            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13817        || (request.requires_spawn_profile_revision_capability()
13818            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13819        || (request.requires_child_environment_profile_capability()
13820            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13821        || (request.requires_session_bundle_materialization_capability()
13822            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13823        || (request.requires_history_context_pack_capability()
13824            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13825}
13826
13827fn snapshot_for_wire(
13828    shared: &NodeShared,
13829    include_provider_runtime_status: bool,
13830    include_open_provider_ids: bool,
13831    include_spawn_profiles: bool,
13832    include_managed_worktrees: bool,
13833    include_child_environment_profiles: bool,
13834    include_session_bundles: bool,
13835    include_history_context_packs: bool,
13836    include_agent_progress: bool,
13837    include_session_task_correlation: bool,
13838) -> NodeSnapshot {
13839    let mut snapshot = shared.snapshot();
13840    if !include_provider_runtime_status {
13841        snapshot.provider_runtime_statuses.clear();
13842    }
13843    if !include_open_provider_ids {
13844        project_snapshot_legacy_provider_ids(&mut snapshot);
13845    }
13846    if !include_managed_worktrees {
13847        snapshot.managed_worktrees.clear();
13848        for workspace in &mut snapshot.workspaces {
13849            workspace.managed_worktree_profiles = None;
13850        }
13851    }
13852    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13853        if !include_spawn_profiles {
13854            inventory.spawn_profiles = None;
13855        }
13856        if !include_session_bundles {
13857            inventory.bundles = None;
13858        }
13859        if inventory.spawn_profiles.is_none()
13860            && inventory.bundles.is_none()
13861            && inventory.network_allowlists.is_none()
13862        {
13863            snapshot.launch_inventory = None;
13864        }
13865    }
13866    if !include_child_environment_profiles {
13867        clear_snapshot_child_environment_profiles(&mut snapshot);
13868    }
13869    if !include_session_bundles {
13870        clear_snapshot_session_bundles(&mut snapshot);
13871    }
13872    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13873    if !include_history_context_packs {
13874        clear_snapshot_context_packs(&mut snapshot);
13875    }
13876    if !include_agent_progress {
13877        snapshot.agent_progress.clear();
13878    }
13879    if !include_session_task_correlation {
13880        clear_snapshot_session_task_bindings(&mut snapshot);
13881    }
13882    snapshot
13883}
13884
13885fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13886    for record in &mut snapshot.session_records {
13887        record.task_binding = None;
13888    }
13889}
13890
13891fn project_event_without_session_task_binding(
13892    mut envelope: NodeEventEnvelope,
13893) -> NodeEventEnvelope {
13894    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13895        record.task_binding = None;
13896    }
13897    envelope
13898}
13899
13900fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13901    let Ok(response) = reply.result.as_mut() else { return };
13902    match response {
13903        NodeResponse::Snapshot { snapshot, .. } => {
13904            clear_snapshot_session_task_bindings(snapshot);
13905        }
13906        NodeResponse::Resync { snapshot, events, .. } => {
13907            clear_snapshot_session_task_bindings(snapshot);
13908            for event in events {
13909                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13910                    record.task_binding = None;
13911                }
13912            }
13913        }
13914        NodeResponse::SessionRecordUpdated { record }
13915        | NodeResponse::ProviderSessionIndexed { record }
13916        | NodeResponse::NativeSessionIndexed { record, .. }
13917        | NodeResponse::SessionRecordResumed { record, .. } => {
13918            record.task_binding = None;
13919        }
13920        _ => {}
13921    }
13922}
13923
13924fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13925    let Ok(response) = reply.result.as_mut() else { return };
13926    match response {
13927        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13928            snapshot.agent_progress.clear();
13929        }
13930        _ => {}
13931    }
13932}
13933
13934fn project_event_without_managed_worktrees(
13935    mut envelope: NodeEventEnvelope,
13936) -> Option<NodeEventEnvelope> {
13937    if matches!(
13938        envelope.event,
13939        NodeEvent::ManagedWorktreeUpserted { .. }
13940            | NodeEvent::ManagedWorktreeRemoved { .. }
13941    ) {
13942        return None;
13943    }
13944    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13945        workspace.managed_worktree_profiles = None;
13946    }
13947    Some(envelope)
13948}
13949
13950fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13951    if matches!(
13952        reply.result,
13953        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13954            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13955    ) {
13956        reply.result = Err(failure(
13957            NodeFailureCode::UnsupportedCapability,
13958            "managed worktree capability was not negotiated",
13959        ));
13960        return;
13961    }
13962    let Ok(response) = reply.result.as_mut() else { return };
13963    match response {
13964        NodeResponse::Snapshot { snapshot, .. } => {
13965            snapshot.managed_worktrees.clear();
13966            for workspace in &mut snapshot.workspaces {
13967                workspace.managed_worktree_profiles = None;
13968            }
13969        }
13970        NodeResponse::Resync { snapshot, events, .. } => {
13971            snapshot.managed_worktrees.clear();
13972            for workspace in &mut snapshot.workspaces {
13973                workspace.managed_worktree_profiles = None;
13974            }
13975            *events = std::mem::take(events)
13976                .into_iter()
13977                .filter_map(project_event_without_managed_worktrees)
13978                .collect();
13979        }
13980        NodeResponse::WorkspaceRegistered { workspace }
13981        | NodeResponse::StandaloneWorkspaceCreated { workspace }
13982        | NodeResponse::WorktreeCreated { workspace, .. } => {
13983            workspace.managed_worktree_profiles = None;
13984        }
13985        NodeResponse::WorkspaceInspected { inspection } => {
13986            inspection.git.managed_worktree = None;
13987        }
13988        _ => {}
13989    }
13990}
13991
13992fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13993    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13994        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13995    });
13996    if includes_open_provider_ids {
13997        return;
13998    }
13999    compatibility
14000        .provider_contracts
14001        .retain(|contract| provider_id_is_legacy(&contract.provider));
14002    compatibility
14003        .provider_adapter_contracts
14004        .retain(|contract| provider_id_is_legacy(&contract.provider));
14005}
14006
14007fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14008    if let Some(spec) = match request {
14009        NodeRequest::SpawnSpec { spec }
14010        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14011        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14012        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14013        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14014        _ => None,
14015    } {
14016        return shared
14017            .resolve_spawn_spec(spec)
14018            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14019    }
14020    request_requires_open_provider_ids_with(
14021        request,
14022        |session| shared.validate_address(session).ok(),
14023        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14024    )
14025}
14026
14027fn request_requires_child_environment_profile(
14028    shared: &NodeShared,
14029    request: &NodeRequest,
14030) -> bool {
14031    if let Some(spec) = match request {
14032        NodeRequest::SpawnSpec { spec }
14033        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14034        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14035        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14036        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14037        _ => None,
14038    } {
14039        return shared
14040            .resolve_spawn_spec(spec)
14041            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14042    }
14043    match request {
14044        NodeRequest::Resume { session, .. }
14045        | NodeRequest::Prompt { session, .. }
14046        | NodeRequest::Paste { session, .. }
14047        | NodeRequest::Input { session, .. }
14048        | NodeRequest::TerminalBytes { session, .. }
14049        | NodeRequest::TerminalControl { session, .. }
14050        | NodeRequest::Resize { session, .. }
14051        | NodeRequest::Interrupt { session }
14052        | NodeRequest::Stop { session, .. }
14053        | NodeRequest::Remove { session }
14054        | NodeRequest::ResolveInteraction { session, .. }
14055        | NodeRequest::SetSessionMode { session, .. }
14056        | NodeRequest::SetSessionConfigOption { session, .. }
14057        | NodeRequest::SetSessionModel { session, .. }
14058        | NodeRequest::DiscoverHistory { session, .. }
14059        | NodeRequest::LoadHistory { session, .. }
14060        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14061        | NodeRequest::ExportContextPack { session } => shared
14062            .session_bindings
14063            .lock()
14064            .unwrap_or_else(|poisoned| poisoned.into_inner())
14065            .get(&session.session.instance_id)
14066            .filter(|binding| {
14067                binding.workspace_id == session.workspace_id
14068                    && binding.generation == session.session.generation
14069            })
14070            .map_or(true, |binding| binding.environment_profile.is_some()),
14071        NodeRequest::RenameSessionRecord { record_id, .. }
14072        | NodeRequest::SetSessionTask { record_id, .. }
14073        | NodeRequest::ResumeSessionRecord { record_id, .. }
14074        | NodeRequest::ForgetSessionRecord { record_id } => shared
14075            .record(record_id)
14076            .map_or(true, |record| record.environment_profile.is_some()),
14077        NodeRequest::ArmHarnessMcpReservation { .. }
14078        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14079        | NodeRequest::ActivateHarnessMcpReservation { .. }
14080        | NodeRequest::AbortHarnessMcpReservation { .. }
14081        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14082        | NodeRequest::RejectHarnessMcpCall { .. }
14083        | NodeRequest::Snapshot
14084        | NodeRequest::Resync { .. }
14085        | NodeRequest::BeginDeliveryStage { .. }
14086        | NodeRequest::PutDeliveryBlobChunk { .. }
14087        | NodeRequest::CommitDeliveryStage { .. }
14088        | NodeRequest::AbortDeliveryStage { .. }
14089        | NodeRequest::BrowseHostDirectories { .. }
14090        | NodeRequest::InspectWorkspace { .. }
14091        | NodeRequest::ReadWorkspaceFile { .. }
14092        | NodeRequest::WriteWorkspaceFile { .. }
14093        | NodeRequest::CreateWorkspaceFile { .. }
14094        | NodeRequest::CreateWorkspaceDirectory { .. }
14095        | NodeRequest::ReadGitHistory { .. }
14096        | NodeRequest::ReadGitDiff { .. }
14097        | NodeRequest::AcquireController { .. }
14098        | NodeRequest::ReleaseController
14099        | NodeRequest::Spawn { .. }
14100        | NodeRequest::SpawnSpec { .. }
14101        | NodeRequest::RegisterWorkspace { .. }
14102        | NodeRequest::CreateStandaloneWorkspace { .. }
14103        | NodeRequest::UnregisterWorkspace { .. }
14104        | NodeRequest::CreateWorktree { .. }
14105        | NodeRequest::RemoveWorktree { .. }
14106        | NodeRequest::SpawnManagedWorktree { .. }
14107        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14108        | NodeRequest::CleanupManagedWorktree { .. }
14109        | NodeRequest::IndexProviderSession { .. }
14110        | NodeRequest::IndexNativeSession { .. }
14111        | NodeRequest::CatalogNativeSessions { .. }
14112        | NodeRequest::PageNativeSessions { .. }
14113        | NodeRequest::PreviewNativeSession { .. }
14114        | NodeRequest::PreviewSessionRecord { .. }
14115        | NodeRequest::ForgetContextPack { .. }
14116        | NodeRequest::ResolveDurableContextPack { .. }
14117        | NodeRequest::ReadContextPack { .. }
14118        | NodeRequest::Shutdown => false,
14119    }
14120}
14121
14122fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14123    if let Some(spec) = match request {
14124        NodeRequest::SpawnSpec { spec }
14125        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14126        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14127        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14128        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14129        _ => None,
14130    } {
14131        return shared
14132            .resolve_spawn_spec(spec)
14133            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14134    }
14135    match request {
14136        NodeRequest::Resume { session, .. }
14137        | NodeRequest::Prompt { session, .. }
14138        | NodeRequest::Paste { session, .. }
14139        | NodeRequest::Input { session, .. }
14140        | NodeRequest::TerminalBytes { session, .. }
14141        | NodeRequest::TerminalControl { session, .. }
14142        | NodeRequest::Resize { session, .. }
14143        | NodeRequest::Interrupt { session }
14144        | NodeRequest::Stop { session, .. }
14145        | NodeRequest::Remove { session }
14146        | NodeRequest::ResolveInteraction { session, .. }
14147        | NodeRequest::SetSessionMode { session, .. }
14148        | NodeRequest::SetSessionConfigOption { session, .. }
14149        | NodeRequest::SetSessionModel { session, .. }
14150        | NodeRequest::DiscoverHistory { session, .. }
14151        | NodeRequest::LoadHistory { session, .. }
14152        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14153        | NodeRequest::ExportContextPack { session } => shared
14154            .session_bindings
14155            .lock()
14156            .unwrap_or_else(|poisoned| poisoned.into_inner())
14157            .get(&session.session.instance_id)
14158            .filter(|binding| {
14159                binding.workspace_id == session.workspace_id
14160                    && binding.generation == session.session.generation
14161            })
14162            .map_or(true, |binding| binding.bundle.is_some()),
14163        NodeRequest::RenameSessionRecord { record_id, .. }
14164        | NodeRequest::SetSessionTask { record_id, .. }
14165        | NodeRequest::ResumeSessionRecord { record_id, .. }
14166        | NodeRequest::ForgetSessionRecord { record_id } => shared
14167            .record(record_id)
14168            .map_or(true, |record| record.bundle.is_some()),
14169        NodeRequest::ArmHarnessMcpReservation { .. }
14170        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14171        | NodeRequest::ActivateHarnessMcpReservation { .. }
14172        | NodeRequest::AbortHarnessMcpReservation { .. }
14173        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14174        | NodeRequest::RejectHarnessMcpCall { .. }
14175        | NodeRequest::Snapshot
14176        | NodeRequest::Resync { .. }
14177        | NodeRequest::BeginDeliveryStage { .. }
14178        | NodeRequest::PutDeliveryBlobChunk { .. }
14179        | NodeRequest::CommitDeliveryStage { .. }
14180        | NodeRequest::AbortDeliveryStage { .. }
14181        | NodeRequest::BrowseHostDirectories { .. }
14182        | NodeRequest::InspectWorkspace { .. }
14183        | NodeRequest::ReadWorkspaceFile { .. }
14184        | NodeRequest::WriteWorkspaceFile { .. }
14185        | NodeRequest::CreateWorkspaceFile { .. }
14186        | NodeRequest::CreateWorkspaceDirectory { .. }
14187        | NodeRequest::ReadGitHistory { .. }
14188        | NodeRequest::ReadGitDiff { .. }
14189        | NodeRequest::AcquireController { .. }
14190        | NodeRequest::ReleaseController
14191        | NodeRequest::Spawn { .. }
14192        | NodeRequest::SpawnSpec { .. }
14193        | NodeRequest::RegisterWorkspace { .. }
14194        | NodeRequest::CreateStandaloneWorkspace { .. }
14195        | NodeRequest::UnregisterWorkspace { .. }
14196        | NodeRequest::CreateWorktree { .. }
14197        | NodeRequest::RemoveWorktree { .. }
14198        | NodeRequest::SpawnManagedWorktree { .. }
14199        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14200        | NodeRequest::CleanupManagedWorktree { .. }
14201        | NodeRequest::IndexProviderSession { .. }
14202        | NodeRequest::IndexNativeSession { .. }
14203        | NodeRequest::CatalogNativeSessions { .. }
14204        | NodeRequest::PageNativeSessions { .. }
14205        | NodeRequest::PreviewNativeSession { .. }
14206        | NodeRequest::PreviewSessionRecord { .. }
14207        | NodeRequest::ForgetContextPack { .. }
14208        | NodeRequest::ResolveDurableContextPack { .. }
14209        | NodeRequest::ReadContextPack { .. }
14210        | NodeRequest::Shutdown => false,
14211    }
14212}
14213
14214fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14215    if request.requires_history_context_pack_capability() {
14216        return true;
14217    }
14218    if let Some(spec) = match request {
14219        NodeRequest::SpawnSpec { spec }
14220        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14221        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14222        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14223        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14224        _ => None,
14225    } {
14226        return shared
14227            .resolve_spawn_spec(spec)
14228            .map_or(true, |resolved| resolved.context_id.is_some());
14229    }
14230    match request {
14231        NodeRequest::Resume { session, .. }
14232        | NodeRequest::Prompt { session, .. }
14233        | NodeRequest::Paste { session, .. }
14234        | NodeRequest::Input { session, .. }
14235        | NodeRequest::TerminalBytes { session, .. }
14236        | NodeRequest::TerminalControl { session, .. }
14237        | NodeRequest::Resize { session, .. }
14238        | NodeRequest::Interrupt { session }
14239        | NodeRequest::Stop { session, .. }
14240        | NodeRequest::Remove { session }
14241        | NodeRequest::ResolveInteraction { session, .. }
14242        | NodeRequest::SetSessionMode { session, .. }
14243        | NodeRequest::SetSessionConfigOption { session, .. }
14244        | NodeRequest::SetSessionModel { session, .. } => shared
14245            .session_bindings
14246            .lock()
14247            .unwrap_or_else(|poisoned| poisoned.into_inner())
14248            .get(&session.session.instance_id)
14249            .filter(|binding| {
14250                binding.workspace_id == session.workspace_id
14251                    && binding.generation == session.session.generation
14252            })
14253            .map_or(true, |binding| binding.context.is_some()),
14254        NodeRequest::RenameSessionRecord { record_id, .. }
14255        | NodeRequest::SetSessionTask { record_id, .. }
14256        | NodeRequest::ResumeSessionRecord { record_id, .. }
14257        | NodeRequest::ForgetSessionRecord { record_id } => shared
14258            .record(record_id)
14259            .map_or(true, |record| record.context.is_some()),
14260        NodeRequest::ArmHarnessMcpReservation { .. }
14261        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14262        | NodeRequest::ActivateHarnessMcpReservation { .. }
14263        | NodeRequest::AbortHarnessMcpReservation { .. }
14264        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14265        | NodeRequest::RejectHarnessMcpCall { .. }
14266        | NodeRequest::Snapshot
14267        | NodeRequest::Resync { .. }
14268        | NodeRequest::BeginDeliveryStage { .. }
14269        | NodeRequest::PutDeliveryBlobChunk { .. }
14270        | NodeRequest::CommitDeliveryStage { .. }
14271        | NodeRequest::AbortDeliveryStage { .. }
14272        | NodeRequest::BrowseHostDirectories { .. }
14273        | NodeRequest::InspectWorkspace { .. }
14274        | NodeRequest::ReadWorkspaceFile { .. }
14275        | NodeRequest::WriteWorkspaceFile { .. }
14276        | NodeRequest::CreateWorkspaceFile { .. }
14277        | NodeRequest::CreateWorkspaceDirectory { .. }
14278        | NodeRequest::ReadGitHistory { .. }
14279        | NodeRequest::ReadGitDiff { .. }
14280        | NodeRequest::AcquireController { .. }
14281        | NodeRequest::ReleaseController
14282        | NodeRequest::Spawn { .. }
14283        | NodeRequest::SpawnSpec { .. }
14284        | NodeRequest::RegisterWorkspace { .. }
14285        | NodeRequest::CreateStandaloneWorkspace { .. }
14286        | NodeRequest::UnregisterWorkspace { .. }
14287        | NodeRequest::CreateWorktree { .. }
14288        | NodeRequest::RemoveWorktree { .. }
14289        | NodeRequest::SpawnManagedWorktree { .. }
14290        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14291        | NodeRequest::CleanupManagedWorktree { .. }
14292        | NodeRequest::IndexProviderSession { .. }
14293        | NodeRequest::IndexNativeSession { .. }
14294        | NodeRequest::CatalogNativeSessions { .. }
14295        | NodeRequest::PageNativeSessions { .. }
14296        | NodeRequest::PreviewNativeSession { .. }
14297        | NodeRequest::PreviewSessionRecord { .. }
14298        | NodeRequest::DiscoverHistory { .. }
14299        | NodeRequest::LoadHistory { .. }
14300        | NodeRequest::ExportContextPackForSessionRecord { .. }
14301        | NodeRequest::ExportContextPack { .. }
14302        | NodeRequest::ForgetContextPack { .. }
14303        | NodeRequest::ResolveDurableContextPack { .. }
14304        | NodeRequest::ReadContextPack { .. }
14305        | NodeRequest::Shutdown => false,
14306    }
14307}
14308
14309fn request_requires_open_provider_ids_with(
14310    request: &NodeRequest,
14311    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14312    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14313) -> bool {
14314    match request {
14315        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14316        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14317        NodeRequest::CatalogNativeSessions { route, .. }
14318        | NodeRequest::PageNativeSessions { route, .. } => {
14319            !provider_id_is_legacy(&route.provider)
14320        }
14321        NodeRequest::PreviewNativeSession { selection, .. }
14322        | NodeRequest::IndexNativeSession { selection, .. } => {
14323            !provider_id_is_legacy(&selection.route.provider)
14324        }
14325        NodeRequest::SpawnSpec { .. }
14326        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14327        NodeRequest::SpawnManagedWorktree { .. }
14328        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14329        NodeRequest::Resume { session, .. }
14330        | NodeRequest::Prompt { session, .. }
14331        | NodeRequest::Paste { session, .. }
14332        | NodeRequest::Input { session, .. }
14333        | NodeRequest::TerminalBytes { session, .. }
14334        | NodeRequest::TerminalControl { session, .. }
14335        | NodeRequest::Resize { session, .. }
14336        | NodeRequest::Interrupt { session }
14337        | NodeRequest::Stop { session, .. }
14338        | NodeRequest::Remove { session }
14339        | NodeRequest::ResolveInteraction { session, .. }
14340        | NodeRequest::SetSessionMode { session, .. }
14341        | NodeRequest::SetSessionConfigOption { session, .. }
14342        | NodeRequest::SetSessionModel { session, .. }
14343        | NodeRequest::DiscoverHistory { session, .. }
14344        | NodeRequest::LoadHistory { session, .. }
14345        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14346        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14347            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14348        NodeRequest::RenameSessionRecord { record_id, .. }
14349        | NodeRequest::SetSessionTask { record_id, .. }
14350        | NodeRequest::ResumeSessionRecord { record_id, .. }
14351        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14352            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14353        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14354            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14355        NodeRequest::ArmHarnessMcpReservation { .. }
14356        | NodeRequest::ActivateHarnessMcpReservation { .. }
14357        | NodeRequest::AbortHarnessMcpReservation { .. }
14358        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14359        | NodeRequest::RejectHarnessMcpCall { .. }
14360        | NodeRequest::Snapshot
14361        | NodeRequest::Resync { .. }
14362        | NodeRequest::BeginDeliveryStage { .. }
14363        | NodeRequest::PutDeliveryBlobChunk { .. }
14364        | NodeRequest::CommitDeliveryStage { .. }
14365        | NodeRequest::AbortDeliveryStage { .. }
14366        | NodeRequest::BrowseHostDirectories { .. }
14367        | NodeRequest::InspectWorkspace { .. }
14368        | NodeRequest::ReadWorkspaceFile { .. }
14369        | NodeRequest::WriteWorkspaceFile { .. }
14370        | NodeRequest::CreateWorkspaceFile { .. }
14371        | NodeRequest::CreateWorkspaceDirectory { .. }
14372        | NodeRequest::ReadGitHistory { .. }
14373        | NodeRequest::ReadGitDiff { .. }
14374        | NodeRequest::AcquireController { .. }
14375        | NodeRequest::ReleaseController
14376        | NodeRequest::RegisterWorkspace { .. }
14377        | NodeRequest::CreateStandaloneWorkspace { .. }
14378        | NodeRequest::UnregisterWorkspace { .. }
14379        | NodeRequest::CreateWorktree { .. }
14380        | NodeRequest::RemoveWorktree { .. }
14381        | NodeRequest::CleanupManagedWorktree { .. }
14382        | NodeRequest::Shutdown => false,
14383        NodeRequest::ForgetContextPack { .. } => true,
14384        // No session/record to cheaply resolve a provider from here either;
14385        // the resolved receipt can carry an open-id provider, so require the
14386        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14387        // is the same digest-keyed lookup one step further into the same
14388        // catalog, so it inherits the same conservative answer.
14389        NodeRequest::ResolveDurableContextPack { .. }
14390        | NodeRequest::ReadContextPack { .. } => true,
14391    }
14392}
14393
14394fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14395    snapshot.enabled_providers.retain(provider_id_is_legacy);
14396    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14397        snapshot
14398            .provider_runtime_statuses
14399            .as_slice()
14400            .iter()
14401            .filter(|status| provider_id_is_legacy(status.provider()))
14402            .cloned(),
14403    )
14404    .expect("a filtered provider runtime projection remains valid");
14405    snapshot
14406        .session_records
14407        .retain(|record| provider_id_is_legacy(&record.provider));
14408    for workspace in &mut snapshot.workspaces {
14409        workspace
14410            .sessions
14411            .retain(|session| provider_id_is_legacy(&session.agent_id));
14412    }
14413}
14414
14415fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14416    for record in &mut snapshot.session_records {
14417        record.environment_profile = None;
14418    }
14419    if let Some(profiles) = snapshot
14420        .launch_inventory
14421        .as_mut()
14422        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14423    {
14424        for profile in profiles {
14425            profile.environment_profile = None;
14426        }
14427    }
14428}
14429
14430fn project_event_without_child_environment_profile(
14431    mut envelope: NodeEventEnvelope,
14432) -> NodeEventEnvelope {
14433    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14434        record.environment_profile = None;
14435    }
14436    envelope
14437}
14438
14439fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14440    let contains_environment_profile = match reply.result.as_ref() {
14441        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14442            receipt.environment_profile.is_some()
14443        }
14444        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14445            receipt.spawn.environment_profile.is_some()
14446        }
14447        _ => false,
14448    };
14449    if contains_environment_profile {
14450        reply.result = Err(failure(
14451            NodeFailureCode::UnsupportedCapability,
14452            "child environment profiles were not negotiated",
14453        ));
14454        return;
14455    }
14456    let Ok(response) = reply.result.as_mut() else {
14457        return;
14458    };
14459    match response {
14460        NodeResponse::Snapshot { snapshot, .. } => {
14461            clear_snapshot_child_environment_profiles(snapshot);
14462        }
14463        NodeResponse::Resync { snapshot, events, .. } => {
14464            clear_snapshot_child_environment_profiles(snapshot);
14465            for event in events {
14466                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14467                    record.environment_profile = None;
14468                }
14469            }
14470        }
14471        NodeResponse::SessionRecordUpdated { record }
14472        | NodeResponse::ProviderSessionIndexed { record }
14473        | NodeResponse::NativeSessionIndexed { record, .. }
14474        | NodeResponse::SessionRecordResumed { record, .. } => {
14475            record.environment_profile = None;
14476        }
14477        NodeResponse::Armed { .. }
14478        | NodeResponse::Spawned { .. }
14479        | NodeResponse::Activated { .. }
14480        | NodeResponse::Aborted { .. }
14481        | NodeResponse::ReplyChunkAccepted { .. }
14482        | NodeResponse::CallRejected { .. }
14483        | NodeResponse::DeliveryStageBegun { .. }
14484        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14485        | NodeResponse::DeliveryCommitted { .. }
14486        | NodeResponse::DeliveryStageAborted { .. }
14487        | NodeResponse::WorkspaceInspected { .. }
14488        | NodeResponse::HostDirectoriesBrowsed { .. }
14489        | NodeResponse::WorkspaceFileRead { .. }
14490        | NodeResponse::WorkspaceFileWritten { .. }
14491        | NodeResponse::WorkspaceFileCreated { .. }
14492        | NodeResponse::WorkspaceDirectoryCreated { .. }
14493        | NodeResponse::GitHistoryRead { .. }
14494        | NodeResponse::GitDiffRead { .. }
14495        | NodeResponse::Controller { .. }
14496        | NodeResponse::SpawnAccepted { .. }
14497        | NodeResponse::SpawnSpecAccepted { .. }
14498        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14499        | NodeResponse::ManagedWorktreeCleanup { .. }
14500        | NodeResponse::NativeSessionsCataloged { .. }
14501        | NodeResponse::NativeSessionsPaged { .. }
14502        | NodeResponse::NativeSessionPreviewed { .. }
14503        | NodeResponse::SessionRecordPreviewed { .. }
14504        | NodeResponse::HistoryDiscovered { .. }
14505        | NodeResponse::HistoryLoaded { .. }
14506        | NodeResponse::ContextPackForSessionRecordExported { .. }
14507        | NodeResponse::ContextPackExported { .. }
14508        | NodeResponse::ContextPackForgotten { .. }
14509        | NodeResponse::DurableContextPackResolved { .. }
14510        | NodeResponse::ContextPackBytesRead { .. }
14511        | NodeResponse::SessionRecordForgotten { .. }
14512        | NodeResponse::WorkspaceRegistered { .. }
14513        | NodeResponse::StandaloneWorkspaceCreated { .. }
14514        | NodeResponse::WorkspaceUnregistered { .. }
14515        | NodeResponse::WorktreeCreated { .. }
14516        | NodeResponse::WorktreeRemoved { .. }
14517        | NodeResponse::Accepted
14518        | NodeResponse::ShuttingDown => {}
14519    }
14520}
14521
14522fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14523    for record in &mut snapshot.session_records {
14524        record.bundle = None;
14525    }
14526}
14527
14528fn project_event_without_session_bundle(
14529    mut envelope: NodeEventEnvelope,
14530) -> NodeEventEnvelope {
14531    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14532        record.bundle = None;
14533    }
14534    envelope
14535}
14536
14537fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14538    let contains_bundle = match reply.result.as_ref() {
14539        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14540        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14541            receipt.spawn.bundle.is_some()
14542        }
14543        _ => false,
14544    };
14545    if contains_bundle {
14546        reply.result = Err(failure(
14547            NodeFailureCode::UnsupportedCapability,
14548            "session bundles were not negotiated",
14549        ));
14550        return;
14551    }
14552    let Ok(response) = reply.result.as_mut() else {
14553        return;
14554    };
14555    match response {
14556        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14557        NodeResponse::Resync { snapshot, events, .. } => {
14558            clear_snapshot_session_bundles(snapshot);
14559            for event in events {
14560                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14561                    record.bundle = None;
14562                }
14563            }
14564        }
14565        NodeResponse::SessionRecordUpdated { record }
14566        | NodeResponse::ProviderSessionIndexed { record }
14567        | NodeResponse::NativeSessionIndexed { record, .. }
14568        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14569        NodeResponse::Armed { .. }
14570        | NodeResponse::Spawned { .. }
14571        | NodeResponse::Activated { .. }
14572        | NodeResponse::Aborted { .. }
14573        | NodeResponse::ReplyChunkAccepted { .. }
14574        | NodeResponse::CallRejected { .. }
14575        | NodeResponse::DeliveryStageBegun { .. }
14576        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14577        | NodeResponse::DeliveryCommitted { .. }
14578        | NodeResponse::DeliveryStageAborted { .. }
14579        | NodeResponse::WorkspaceInspected { .. }
14580        | NodeResponse::HostDirectoriesBrowsed { .. }
14581        | NodeResponse::WorkspaceFileRead { .. }
14582        | NodeResponse::WorkspaceFileWritten { .. }
14583        | NodeResponse::WorkspaceFileCreated { .. }
14584        | NodeResponse::WorkspaceDirectoryCreated { .. }
14585        | NodeResponse::GitHistoryRead { .. }
14586        | NodeResponse::GitDiffRead { .. }
14587        | NodeResponse::Controller { .. }
14588        | NodeResponse::SpawnAccepted { .. }
14589        | NodeResponse::SpawnSpecAccepted { .. }
14590        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14591        | NodeResponse::ManagedWorktreeCleanup { .. }
14592        | NodeResponse::NativeSessionsCataloged { .. }
14593        | NodeResponse::NativeSessionsPaged { .. }
14594        | NodeResponse::NativeSessionPreviewed { .. }
14595        | NodeResponse::SessionRecordPreviewed { .. }
14596        | NodeResponse::HistoryDiscovered { .. }
14597        | NodeResponse::HistoryLoaded { .. }
14598        | NodeResponse::ContextPackForSessionRecordExported { .. }
14599        | NodeResponse::ContextPackExported { .. }
14600        | NodeResponse::ContextPackForgotten { .. }
14601        | NodeResponse::DurableContextPackResolved { .. }
14602        | NodeResponse::ContextPackBytesRead { .. }
14603        | NodeResponse::SessionRecordForgotten { .. }
14604        | NodeResponse::WorkspaceRegistered { .. }
14605        | NodeResponse::StandaloneWorkspaceCreated { .. }
14606        | NodeResponse::WorkspaceUnregistered { .. }
14607        | NodeResponse::WorktreeCreated { .. }
14608        | NodeResponse::WorktreeRemoved { .. }
14609        | NodeResponse::Accepted
14610        | NodeResponse::ShuttingDown => {}
14611    }
14612}
14613
14614fn project_snapshot_history_for_wire(
14615    snapshot: &mut NodeSnapshot,
14616    include_history_metadata: bool,
14617) {
14618    for workspace in &mut snapshot.workspaces {
14619        for session in &mut workspace.sessions {
14620            if include_history_metadata {
14621                session.history.loaded = None;
14622            } else {
14623                session.history = Default::default();
14624            }
14625        }
14626    }
14627}
14628
14629fn project_event_history_for_wire(
14630    mut envelope: NodeEventEnvelope,
14631    include_history_metadata: bool,
14632) -> NodeEventEnvelope {
14633    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14634        for session in &mut workspace.sessions {
14635            if include_history_metadata {
14636                session.history.loaded = None;
14637            } else {
14638                session.history = Default::default();
14639            }
14640        }
14641    }
14642    envelope
14643}
14644
14645fn project_response_history_for_wire(
14646    reply: &mut ResponseEnvelope,
14647    include_history_metadata: bool,
14648) {
14649    let Ok(response) = reply.result.as_mut() else {
14650        return;
14651    };
14652    match response {
14653        NodeResponse::Snapshot { snapshot, .. } => {
14654            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14655        }
14656        NodeResponse::Resync {
14657            snapshot, events, ..
14658        } => {
14659            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14660            for event in events {
14661                *event = project_event_history_for_wire(
14662                    event.clone(),
14663                    include_history_metadata,
14664                );
14665            }
14666        }
14667        NodeResponse::WorkspaceRegistered { workspace }
14668        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14669        | NodeResponse::WorktreeCreated { workspace, .. } => {
14670            for session in &mut workspace.sessions {
14671                if include_history_metadata {
14672                    session.history.loaded = None;
14673                } else {
14674                    session.history = Default::default();
14675                }
14676            }
14677        }
14678        _ => {}
14679    }
14680}
14681
14682fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14683    for record in &mut snapshot.session_records {
14684        record.context_id = None;
14685        record.context = None;
14686        record.exported_context = None;
14687    }
14688}
14689
14690fn project_event_without_context_pack(
14691    mut envelope: NodeEventEnvelope,
14692) -> NodeEventEnvelope {
14693    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14694        record.context_id = None;
14695        record.context = None;
14696        record.exported_context = None;
14697    }
14698    envelope
14699}
14700
14701fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14702    let contains_context = match reply.result.as_ref() {
14703        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14704        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14705            receipt.spawn.context.is_some()
14706        }
14707        Ok(NodeResponse::HistoryDiscovered { .. })
14708        | Ok(NodeResponse::HistoryLoaded { .. })
14709        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14710        | Ok(NodeResponse::ContextPackExported { .. })
14711        | Ok(NodeResponse::ContextPackForgotten { .. })
14712        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14713        _ => false,
14714    };
14715    if contains_context {
14716        reply.result = Err(failure(
14717            NodeFailureCode::UnsupportedCapability,
14718            "history context packs were not negotiated",
14719        ));
14720        return;
14721    }
14722    let Ok(response) = reply.result.as_mut() else {
14723        return;
14724    };
14725    match response {
14726        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14727        NodeResponse::Resync {
14728            snapshot, events, ..
14729        } => {
14730            clear_snapshot_context_packs(snapshot);
14731            for event in events {
14732                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14733                    record.context_id = None;
14734                    record.context = None;
14735                    record.exported_context = None;
14736                }
14737            }
14738        }
14739        NodeResponse::SessionRecordUpdated { record }
14740        | NodeResponse::ProviderSessionIndexed { record }
14741        | NodeResponse::NativeSessionIndexed { record, .. }
14742        | NodeResponse::SessionRecordResumed { record, .. } => {
14743            record.context_id = None;
14744            record.context = None;
14745            record.exported_context = None;
14746        }
14747        _ => {}
14748    }
14749}
14750
14751fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14752    let contains_open_record = match reply.result.as_ref() {
14753        Ok(NodeResponse::SessionRecordUpdated { record })
14754        | Ok(NodeResponse::ProviderSessionIndexed { record })
14755        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14756            !provider_id_is_legacy(&record.provider)
14757        }
14758        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14759            !provider_id_is_legacy(&selection.route.provider)
14760                || !provider_id_is_legacy(&record.provider)
14761        }
14762        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14763            !provider_id_is_legacy(&receipt.provider)
14764        }
14765        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14766            !provider_id_is_legacy(&receipt.spawn.provider)
14767        }
14768        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14769        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14770            !provider_id_is_legacy(&route.provider)
14771        }
14772        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14773            !provider_id_is_legacy(&selection.route.provider)
14774        }
14775        _ => false,
14776    };
14777    if contains_open_record {
14778        reply.result = Err(failure(
14779            NodeFailureCode::UnsupportedCapability,
14780            "open provider IDs were not negotiated",
14781        ));
14782        return;
14783    }
14784    let Ok(response) = reply.result.as_mut() else {
14785        return;
14786    };
14787    match response {
14788        NodeResponse::Snapshot { snapshot, .. } => {
14789            project_snapshot_legacy_provider_ids(snapshot);
14790        }
14791        NodeResponse::Resync { snapshot, events, .. } => {
14792            project_snapshot_legacy_provider_ids(snapshot);
14793            *events = std::mem::take(events)
14794                .into_iter()
14795                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14796                .collect();
14797        }
14798        NodeResponse::WorkspaceRegistered { workspace }
14799        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14800        | NodeResponse::WorktreeCreated { workspace, .. } => {
14801            workspace
14802                .sessions
14803                .retain(|session| provider_id_is_legacy(&session.agent_id));
14804        }
14805        NodeResponse::Armed { .. }
14806        | NodeResponse::Spawned { .. }
14807        | NodeResponse::Activated { .. }
14808        | NodeResponse::Aborted { .. }
14809        | NodeResponse::ReplyChunkAccepted { .. }
14810        | NodeResponse::CallRejected { .. }
14811        | NodeResponse::DeliveryStageBegun { .. }
14812        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14813        | NodeResponse::DeliveryCommitted { .. }
14814        | NodeResponse::DeliveryStageAborted { .. }
14815        | NodeResponse::WorkspaceInspected { .. }
14816        | NodeResponse::HostDirectoriesBrowsed { .. }
14817        | NodeResponse::WorkspaceFileRead { .. }
14818        | NodeResponse::WorkspaceFileWritten { .. }
14819        | NodeResponse::WorkspaceFileCreated { .. }
14820        | NodeResponse::WorkspaceDirectoryCreated { .. }
14821        | NodeResponse::GitHistoryRead { .. }
14822        | NodeResponse::GitDiffRead { .. }
14823        | NodeResponse::Controller { .. }
14824        | NodeResponse::SpawnAccepted { .. }
14825        | NodeResponse::SpawnSpecAccepted { .. }
14826        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14827        | NodeResponse::ManagedWorktreeCleanup { .. }
14828        | NodeResponse::NativeSessionsCataloged { .. }
14829        | NodeResponse::NativeSessionsPaged { .. }
14830        | NodeResponse::NativeSessionPreviewed { .. }
14831        | NodeResponse::SessionRecordPreviewed { .. }
14832        | NodeResponse::HistoryDiscovered { .. }
14833        | NodeResponse::HistoryLoaded { .. }
14834        | NodeResponse::ContextPackForSessionRecordExported { .. }
14835        | NodeResponse::ContextPackExported { .. }
14836        | NodeResponse::ContextPackForgotten { .. }
14837        | NodeResponse::DurableContextPackResolved { .. }
14838        | NodeResponse::ContextPackBytesRead { .. }
14839        | NodeResponse::SessionRecordUpdated { .. }
14840        | NodeResponse::ProviderSessionIndexed { .. }
14841        | NodeResponse::NativeSessionIndexed { .. }
14842        | NodeResponse::SessionRecordResumed { .. }
14843        | NodeResponse::SessionRecordForgotten { .. }
14844        | NodeResponse::WorkspaceUnregistered { .. }
14845        | NodeResponse::WorktreeRemoved { .. }
14846        | NodeResponse::Accepted
14847        | NodeResponse::ShuttingDown => {}
14848    }
14849}
14850
14851fn project_event_legacy_provider_ids(
14852    shared: &NodeShared,
14853    mut envelope: NodeEventEnvelope,
14854) -> Option<NodeEventEnvelope> {
14855    let include = match &mut envelope.event {
14856        NodeEvent::HarnessMcpReadCall { .. } => true,
14857        NodeEvent::Control { address, .. }
14858        | NodeEvent::AgentStream { address, .. }
14859        | NodeEvent::TerminalFrame { address, .. } => shared
14860            .validate_address(address)
14861            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14862        NodeEvent::WorkspaceAdded { workspace } => {
14863            workspace
14864                .sessions
14865                .retain(|session| provider_id_is_legacy(&session.agent_id));
14866            true
14867        }
14868        NodeEvent::SessionRecordUpserted { record } => {
14869            provider_id_is_legacy(&record.provider)
14870        }
14871        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14872        NodeEvent::SessionRecordRemoved { .. } => shared
14873            .history
14874            .lock()
14875            .unwrap_or_else(|poisoned| poisoned.into_inner())
14876            .removed_record_providers
14877            .get(&envelope.sequence)
14878            .is_some_and(provider_id_is_legacy),
14879        NodeEvent::ControllerChanged { .. }
14880        | NodeEvent::ManagedWorktreeUpserted { .. }
14881        | NodeEvent::ManagedWorktreeRemoved { .. }
14882        | NodeEvent::WorkspaceRemoved { .. }
14883        | NodeEvent::ResyncRequired { .. } => true,
14884    };
14885    include.then_some(envelope)
14886}
14887
14888fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14889    let Ok(response) = reply.result.as_mut() else {
14890        return;
14891    };
14892    match response {
14893        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14894            snapshot.provider_runtime_statuses.clear();
14895        }
14896        NodeResponse::Armed { .. }
14897        | NodeResponse::Spawned { .. }
14898        | NodeResponse::Activated { .. }
14899        | NodeResponse::Aborted { .. }
14900        | NodeResponse::ReplyChunkAccepted { .. }
14901        | NodeResponse::CallRejected { .. }
14902        | NodeResponse::DeliveryStageBegun { .. }
14903        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14904        | NodeResponse::DeliveryCommitted { .. }
14905        | NodeResponse::DeliveryStageAborted { .. }
14906        | NodeResponse::WorkspaceInspected { .. }
14907        | NodeResponse::HostDirectoriesBrowsed { .. }
14908        | NodeResponse::WorkspaceFileRead { .. }
14909        | NodeResponse::WorkspaceFileWritten { .. }
14910        | NodeResponse::WorkspaceFileCreated { .. }
14911        | NodeResponse::WorkspaceDirectoryCreated { .. }
14912        | NodeResponse::GitHistoryRead { .. }
14913        | NodeResponse::GitDiffRead { .. }
14914        | NodeResponse::Controller { .. }
14915        | NodeResponse::SpawnAccepted { .. }
14916        | NodeResponse::SpawnSpecAccepted { .. }
14917        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14918        | NodeResponse::ManagedWorktreeCleanup { .. }
14919        | NodeResponse::NativeSessionsCataloged { .. }
14920        | NodeResponse::NativeSessionsPaged { .. }
14921        | NodeResponse::NativeSessionPreviewed { .. }
14922        | NodeResponse::SessionRecordPreviewed { .. }
14923        | NodeResponse::HistoryDiscovered { .. }
14924        | NodeResponse::HistoryLoaded { .. }
14925        | NodeResponse::ContextPackForSessionRecordExported { .. }
14926        | NodeResponse::ContextPackExported { .. }
14927        | NodeResponse::ContextPackForgotten { .. }
14928        | NodeResponse::DurableContextPackResolved { .. }
14929        | NodeResponse::ContextPackBytesRead { .. }
14930        | NodeResponse::SessionRecordUpdated { .. }
14931        | NodeResponse::ProviderSessionIndexed { .. }
14932        | NodeResponse::NativeSessionIndexed { .. }
14933        | NodeResponse::SessionRecordResumed { .. }
14934        | NodeResponse::SessionRecordForgotten { .. }
14935        | NodeResponse::WorkspaceRegistered { .. }
14936        | NodeResponse::StandaloneWorkspaceCreated { .. }
14937        | NodeResponse::WorkspaceUnregistered { .. }
14938        | NodeResponse::WorktreeCreated { .. }
14939        | NodeResponse::WorktreeRemoved { .. }
14940        | NodeResponse::Accepted
14941        | NodeResponse::ShuttingDown => {}
14942    }
14943}
14944
14945struct ControllerReleaseGuard {
14946    shared: Arc<NodeShared>,
14947    connection_id: u64,
14948}
14949
14950impl Drop for ControllerReleaseGuard {
14951    fn drop(&mut self) {
14952        self.shared.release_controller(self.connection_id);
14953    }
14954}
14955
14956struct AbortTaskOnDrop(AbortHandle);
14957
14958impl Drop for AbortTaskOnDrop {
14959    fn drop(&mut self) {
14960        self.0.abort();
14961    }
14962}
14963
14964/// Reads an unsigned env-config override, following the pattern of every
14965/// other bounded knob in this module: missing/unparsable/zero falls back to
14966/// `default`, and the resolved value is always clamped to `max` so a bad
14967/// override cannot reopen a bound this budget exists to enforce.
14968fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14969    std::env::var(name)
14970        .ok()
14971        .and_then(|value| value.parse::<u64>().ok())
14972        .filter(|value| *value > 0)
14973        .unwrap_or(default)
14974        .min(max)
14975}
14976
14977fn workspace_inspection_time_budget_ms() -> u64 {
14978    env_config_bounded(
14979        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14980        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14981        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14982    )
14983}
14984
14985fn workspace_inspection_entry_cap() -> usize {
14986    env_config_bounded(
14987        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14988        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14989        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14990    ) as usize
14991}
14992
14993/// Tracks the shared walk-side budget across the whole recursive walk: a
14994/// wall-clock deadline (shared with the git phase that runs after the walk)
14995/// and a cap on directory entries visited, whether or not they were pushed
14996/// to the response. Cheap to check per entry — one `Instant::now()` and one
14997/// integer compare.
14998struct WorkspaceWalkBudget {
14999    deadline: Instant,
15000    entry_cap: usize,
15001    entries_visited: u64,
15002    time_budget_exceeded: bool,
15003    entry_cap_exceeded: bool,
15004}
15005
15006impl WorkspaceWalkBudget {
15007    fn new(deadline: Instant, entry_cap: usize) -> Self {
15008        Self {
15009            deadline,
15010            entry_cap,
15011            entries_visited: 0,
15012            time_budget_exceeded: false,
15013            entry_cap_exceeded: false,
15014        }
15015    }
15016
15017    fn is_exhausted(&self) -> bool {
15018        self.time_budget_exceeded || self.entry_cap_exceeded
15019    }
15020
15021    /// Records one visited directory entry (pushed to the response or not)
15022    /// and reports whether the walk must stop now.
15023    fn record_visit(&mut self) -> bool {
15024        self.entries_visited += 1;
15025        if self.entries_visited as usize >= self.entry_cap {
15026            self.entry_cap_exceeded = true;
15027        }
15028        if Instant::now() >= self.deadline {
15029            self.time_budget_exceeded = true;
15030        }
15031        self.is_exhausted()
15032    }
15033}
15034
15035fn collect_workspace_entries(
15036    root: &Path,
15037    deadline: Instant,
15038    entry_cap: usize,
15039) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15040    let mut entries = Vec::new();
15041    let mut truncated = false;
15042    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15043    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15044    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15045    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15046    // require strict global byte order over relative paths.
15047    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15048    (entries, truncated, budget)
15049}
15050
15051/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15052/// `inspect_workspace` response — `None` once the walk and git phase both
15053/// completed inside their shared budget.
15054fn workspace_inspection_truncation(
15055    walk_budget: &WorkspaceWalkBudget,
15056    git_time_budget_exceeded: bool,
15057    elapsed: Duration,
15058) -> Option<WorkspaceInspectionTruncationV1> {
15059    if !walk_budget.time_budget_exceeded
15060        && !walk_budget.entry_cap_exceeded
15061        && !git_time_budget_exceeded
15062    {
15063        return None;
15064    }
15065    Some(WorkspaceInspectionTruncationV1 {
15066        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15067        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15068        git_time_budget_exceeded,
15069        entries_visited: walk_budget.entries_visited,
15070        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15071    })
15072}
15073
15074fn walk_workspace_directory(
15075    directory: &Path,
15076    relative_directory: Option<&RepositoryPath>,
15077    depth: usize,
15078    entries: &mut Vec<WorkspaceEntry>,
15079    truncated: &mut bool,
15080    budget: &mut WorkspaceWalkBudget,
15081) {
15082    if budget.is_exhausted() {
15083        *truncated = true;
15084        return;
15085    }
15086    let read_dir = match std::fs::read_dir(directory) {
15087        Ok(read_dir) => read_dir,
15088        Err(_) => {
15089            *truncated = true;
15090            return;
15091        }
15092    };
15093    let mut children = Vec::new();
15094    for child in read_dir {
15095        match child {
15096            Ok(child) => children.push(child),
15097            Err(_) => *truncated = true,
15098        }
15099    }
15100    children.sort_by_key(|child| child.file_name());
15101    for child in children {
15102        if budget.record_visit() {
15103            *truncated = true;
15104            return;
15105        }
15106        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15107            *truncated = true;
15108            return;
15109        }
15110        let file_type = match child.file_type() {
15111            Ok(file_type) => file_type,
15112            Err(_) => {
15113                *truncated = true;
15114                continue;
15115            }
15116        };
15117        if file_type.is_symlink() {
15118            continue;
15119        }
15120        let name = match child.file_name().into_string() {
15121            Ok(name) => name,
15122            Err(_) => {
15123                *truncated = true;
15124                continue;
15125            }
15126        };
15127        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15128            continue;
15129        }
15130        let relative_path = match relative_directory {
15131            None => windows_repository_path(name),
15132            Some(parent) => parent
15133                .as_utf8()
15134                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15135        };
15136        let Some(relative_path) = relative_path else {
15137            *truncated = true;
15138            continue;
15139        };
15140        let kind = if file_type.is_dir() {
15141            WorkspaceEntryKind::Directory
15142        } else if file_type.is_file() {
15143            WorkspaceEntryKind::File
15144        } else {
15145            continue;
15146        };
15147        entries.push(WorkspaceEntry {
15148            relative_path: relative_path.clone(),
15149            kind,
15150        });
15151        if file_type.is_dir() {
15152            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15153                walk_workspace_directory(
15154                    &child.path(),
15155                    Some(&relative_path),
15156                    depth + 1,
15157                    entries,
15158                    truncated,
15159                    budget,
15160                );
15161            } else {
15162                *truncated = true;
15163            }
15164        }
15165    }
15166}
15167
15168fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15169    if value.contains('\\') {
15170        return None;
15171    }
15172    RepositoryPath::utf8(value).ok()
15173}
15174
15175fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15176    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15177}
15178
15179/// Excludes a directory from the workspace tree walk. The three literal
15180/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15181/// beneath them catches every build-output directory regardless of name
15182/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15183/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15184/// directory without maintaining a name heuristic list). The name check
15185/// runs first and returns early so a directory rejected by name never pays
15186/// for the extra `CACHEDIR.TAG` stat.
15187fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15188    if name.eq_ignore_ascii_case(".git")
15189        || name.eq_ignore_ascii_case("target")
15190        || name.eq_ignore_ascii_case("node_modules")
15191    {
15192        return true;
15193    }
15194    path.join("CACHEDIR.TAG").is_file()
15195}
15196
15197fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15198    matches!(
15199        status,
15200        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15201    )
15202}
15203
15204#[derive(Clone, Debug, Eq, PartialEq)]
15205enum ContextPackRepositoryHead {
15206    NotRepository,
15207    Unborn,
15208    Commit(String),
15209}
15210
15211#[derive(Clone, Debug, Eq, PartialEq)]
15212struct ContextPackRepositoryObservation {
15213    head: ContextPackRepositoryHead,
15214    git: GitSnapshot,
15215    files: Vec<ContextPackRepositoryFileSource>,
15216}
15217
15218fn stable_context_pack_repository(
15219    first: ContextPackRepositoryObservation,
15220    second: ContextPackRepositoryObservation,
15221) -> Result<ContextPackRepository, NodeFailure> {
15222    if first != second {
15223        return Err(failure(
15224            NodeFailureCode::ContextPackMaterializationFailed,
15225            "context repository changed during bounded capture",
15226        ));
15227    }
15228    Ok(ContextPackRepository::from_sources(
15229        &second.git,
15230        second.files,
15231    ))
15232}
15233
15234/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15235/// (and only once) the shared walk+git `deadline` has already passed. Every
15236/// remaining phase in `inspect_git_workspace` checks this immediately
15237/// before issuing its own git call, so a budget spent by the walk (or by an
15238/// earlier git call) skips the rest of the chain instead of paying for it
15239/// anyway.
15240fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15241    if Instant::now() < deadline {
15242        return false;
15243    }
15244    append_git_diagnostic(
15245        snapshot,
15246        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15247    );
15248    true
15249}
15250
15251/// Deadline-aware sibling of `run_git_bounded`, used only by
15252/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15253/// whatever remains of the shared `deadline` (never more than
15254/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15255/// of the budget cannot still hand a full per-call timeout to every git
15256/// probe behind it. `inspect_git_workspace` already guards each call site
15257/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15258/// passed yet here.
15259async fn run_git_bounded_with_deadline(
15260    root: &str,
15261    arguments: &[&str],
15262    output_limit: usize,
15263    deadline: Instant,
15264) -> io::Result<GitCommandOutput> {
15265    let remaining = deadline.checked_duration_since(Instant::now());
15266    let timeout_ms = remaining
15267        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15268        .unwrap_or(0)
15269        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15270    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15271}
15272
15273/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15274/// `deadline` is the same absolute instant the preceding directory walk was
15275/// bounded by, so time already spent walking counts against what remains
15276/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15277/// shared budget (not an individual per-call timeout) is what cut the git
15278/// phase short.
15279async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15280    let mut snapshot = GitSnapshot {
15281        is_repository: false,
15282        branch: None,
15283        status: Vec::new(),
15284        recent_commits: Vec::new(),
15285        worktrees: Vec::new(),
15286        managed_worktree: None,
15287        truncated: false,
15288        diagnostic: None,
15289    };
15290    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15291        return (snapshot, true);
15292    }
15293    let repository = match run_git_bounded_with_deadline(
15294        root,
15295        &["rev-parse", "--is-inside-work-tree"],
15296        4 * 1_024,
15297        deadline,
15298    )
15299    .await
15300    {
15301        Ok(output) => output,
15302        Err(error) => {
15303            append_git_diagnostic(
15304                &mut snapshot,
15305                &format!("git inspection unavailable: {error}"),
15306            );
15307            return (snapshot, false);
15308        }
15309    };
15310    snapshot.truncated |= repository.truncated;
15311    if repository.timed_out {
15312        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15313        return (snapshot, false);
15314    }
15315    if !repository.success
15316        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15317    {
15318        let stderr = String::from_utf8_lossy(&repository.stderr);
15319        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15320            append_git_diagnostic(&mut snapshot, stderr.trim());
15321        }
15322        return (snapshot, false);
15323    }
15324    snapshot.is_repository = true;
15325
15326    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15327        return (snapshot, true);
15328    }
15329    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15330        Ok(output) => {
15331            snapshot.truncated |= output.truncated;
15332            if output.timed_out {
15333                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15334            } else if output.success {
15335                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15336                if !branch.is_empty() {
15337                    snapshot.branch = Some(truncate_git_field(
15338                        &branch,
15339                        MAX_REPOSITORY_PATH_BYTES,
15340                        &mut snapshot.truncated,
15341                    ));
15342                }
15343            }
15344        }
15345        Err(error) => append_git_diagnostic(
15346            &mut snapshot,
15347            &format!("git branch query failed: {error}"),
15348        ),
15349    }
15350    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15351        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15352            snapshot.truncated |= output.truncated;
15353            if output.success && !output.timed_out {
15354                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15355                if !head.is_empty() {
15356                    snapshot.branch = Some(format!("detached:{head}"));
15357                }
15358            }
15359        }
15360    }
15361
15362    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15363        return (snapshot, true);
15364    }
15365    match run_git_bounded_with_deadline(
15366        root,
15367        &[
15368            "status",
15369            "--porcelain=v1",
15370            "-z",
15371            "--renames",
15372            "--untracked-files=normal",
15373            "--",
15374            ".",
15375        ],
15376        GIT_OUTPUT_MAX_BYTES,
15377        deadline,
15378    )
15379    .await
15380    {
15381        Ok(output) => {
15382            snapshot.truncated |= output.truncated;
15383            if output.timed_out {
15384                append_git_diagnostic(&mut snapshot, "git status timed out");
15385            } else if output.success {
15386                parse_git_status(&output.stdout, &mut snapshot);
15387            } else {
15388                append_git_diagnostic(
15389                    &mut snapshot,
15390                    String::from_utf8_lossy(&output.stderr).trim(),
15391                );
15392            }
15393        }
15394        Err(error) => append_git_diagnostic(
15395            &mut snapshot,
15396            &format!("git status failed: {error}"),
15397        ),
15398    }
15399
15400    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15401        return (snapshot, true);
15402    }
15403    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15404    match run_git_bounded_with_deadline(
15405        root,
15406        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15407        16 * 1_024,
15408        deadline,
15409    )
15410    .await
15411    {
15412        Ok(output) => {
15413            snapshot.truncated |= output.truncated;
15414            if output.timed_out {
15415                append_git_diagnostic(&mut snapshot, "git log timed out");
15416            } else if output.success {
15417                parse_git_commits(&output.stdout, &mut snapshot);
15418            } else {
15419                let stderr = String::from_utf8_lossy(&output.stderr);
15420                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15421                    append_git_diagnostic(&mut snapshot, stderr.trim());
15422                }
15423            }
15424        }
15425        Err(error) => append_git_diagnostic(
15426            &mut snapshot,
15427            &format!("git log failed: {error}"),
15428        ),
15429    }
15430
15431    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15432        return (snapshot, true);
15433    }
15434    match list_git_worktrees_with_deadline(root, deadline).await {
15435        Ok(worktrees) => {
15436            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15437        }
15438        Err(error) => append_git_diagnostic(
15439            &mut snapshot,
15440            &format!("git worktree list failed: {}", error.message),
15441        ),
15442    }
15443    (snapshot, false)
15444}
15445
15446fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15447    let mut remaining = output;
15448    while !remaining.is_empty() {
15449        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15450            snapshot.truncated = true;
15451            break;
15452        }
15453        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15454            snapshot.truncated = true;
15455            break;
15456        };
15457        let record = &remaining[..record_end];
15458        remaining = &remaining[record_end + 1..];
15459        let has_previous_path = record
15460            .get(..2)
15461            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15462        let previous_path_bytes = if has_previous_path {
15463            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15464                snapshot.truncated = true;
15465                break;
15466            };
15467            let previous = &remaining[..previous_end];
15468            remaining = &remaining[previous_end + 1..];
15469            Some(previous)
15470        } else {
15471            None
15472        };
15473        if record.len() < 4
15474            || record[2] != b' '
15475            || !is_git_status_code(record[0])
15476            || !is_git_status_code(record[1])
15477        {
15478            snapshot.truncated = true;
15479            continue;
15480        }
15481        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15482            snapshot.truncated = true;
15483            continue;
15484        };
15485        let previous_path = match previous_path_bytes {
15486            Some(previous) => {
15487                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15488                    snapshot.truncated = true;
15489                    continue;
15490                };
15491                Some(previous)
15492            }
15493            None => None,
15494        };
15495        snapshot.status.push(GitStatusEntry {
15496            index_status: (record[0] as char).to_string(),
15497            worktree_status: (record[1] as char).to_string(),
15498            path,
15499            previous_path,
15500        });
15501    }
15502}
15503
15504fn is_git_status_code(byte: u8) -> bool {
15505    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15506}
15507
15508fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15509    for line in String::from_utf8_lossy(output).lines() {
15510        let Some((id, summary)) = line.split_once('\u{1f}') else {
15511            continue;
15512        };
15513        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15514            snapshot.truncated = true;
15515            break;
15516        }
15517        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15518        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15519        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15520    }
15521}
15522
15523fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15524    if value.len() <= max_bytes {
15525        return value.to_owned();
15526    }
15527    *truncated = true;
15528    let mut boundary = max_bytes;
15529    while boundary > 0 && !value.is_char_boundary(boundary) {
15530        boundary -= 1;
15531    }
15532    value[..boundary].to_owned()
15533}
15534
15535fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15536    if message.is_empty() {
15537        return;
15538    }
15539    let normalized = message
15540        .chars()
15541        .map(|character| if character.is_control() { ' ' } else { character })
15542        .collect::<String>();
15543    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15544    if !diagnostic.is_empty() {
15545        diagnostic.push_str("; ");
15546    }
15547    diagnostic.push_str(normalized.trim());
15548    snapshot.diagnostic = Some(truncate_git_field(
15549        &diagnostic,
15550        GIT_DIAGNOSTIC_MAX_BYTES,
15551        &mut snapshot.truncated,
15552    ));
15553}
15554
15555async fn run_git_bounded(
15556    root: &str,
15557    arguments: &[&str],
15558    output_limit: usize,
15559) -> io::Result<GitCommandOutput> {
15560    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15561}
15562
15563async fn read_git_history_bounded(
15564    root: &str,
15565    path: Option<&RepositoryPath>,
15566    before: Option<&GitObjectId>,
15567    limit: u16,
15568) -> Result<GitHistoryPage, NodeFailure> {
15569    let path = path
15570        .map(|path| {
15571            path.as_utf8().ok_or_else(|| {
15572                failure(
15573                    NodeFailureCode::InvalidRepositoryPath,
15574                    "git history path must use UTF-8",
15575                )
15576            })
15577        })
15578        .transpose()?
15579        .unwrap_or(".");
15580    let count = (usize::from(limit) + 1).to_string();
15581    let start = before
15582        .map(|revision| format!("{}^", revision.as_str()))
15583        .unwrap_or_else(|| "HEAD".to_owned());
15584    let arguments = [
15585        "--no-pager",
15586        "log",
15587        "--no-decorate",
15588        "--date=iso-strict",
15589        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15590        "-n",
15591        count.as_str(),
15592        start.as_str(),
15593        "--",
15594        path,
15595    ];
15596    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15597        .await
15598        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15599    if output.timed_out {
15600        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15601    }
15602    if !output.success {
15603        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15604    }
15605    let mut commits = parse_git_history(&output.stdout);
15606    let has_more = commits.len() > usize::from(limit) || output.truncated;
15607    commits.truncate(usize::from(limit));
15608    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15609    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15610}
15611
15612fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15613    String::from_utf8_lossy(output)
15614        .split('\u{1e}')
15615        .filter_map(|record| {
15616            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15617            if fields.len() != 9 {
15618                return None;
15619            }
15620            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15621            let parents = if fields[1].is_empty() {
15622                Vec::new()
15623            } else {
15624                fields[1]
15625                    .split(' ')
15626                    .map(|value| GitObjectId::new(value.to_owned()))
15627                    .collect::<Result<Vec<_>, _>>()
15628                    .ok()?
15629            };
15630            Some(GitCommitDetails {
15631                id,
15632                parents,
15633                subject: bounded_git_text(fields[2], 512),
15634                author_name: bounded_git_text(fields[3], 256),
15635                author_email: bounded_git_text(fields[4], 320),
15636                authored_at: bounded_git_text(fields[5], 64),
15637                committer_name: bounded_git_text(fields[6], 256),
15638                committer_email: bounded_git_text(fields[7], 320),
15639                committed_at: bounded_git_text(fields[8], 64),
15640                signature_status: GitSignatureStatus::NoSignature,
15641                signer: None,
15642            })
15643        })
15644        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15645        .collect()
15646}
15647
15648fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15649    let normalized = value
15650        .chars()
15651        .map(|character| if character.is_control() { ' ' } else { character })
15652        .collect::<String>();
15653    if normalized.len() <= max_bytes {
15654        return normalized;
15655    }
15656    let mut boundary = max_bytes;
15657    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15658        boundary -= 1;
15659    }
15660    normalized[..boundary].to_owned()
15661}
15662
15663async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15664    let path = request.path.as_ref().map(|path| {
15665        path.as_utf8()
15666            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15667    }).transpose()?;
15668    let mut arguments = match &request.mode {
15669        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15670        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15671        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15672    };
15673    if let GitDiffMode::Commit { revision } = &request.mode {
15674        arguments.push(revision.as_str());
15675    }
15676    arguments.push("--");
15677    if let Some(path) = path {
15678        arguments.push(path);
15679    }
15680    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15681        .await
15682        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15683    if output.timed_out {
15684        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15685    }
15686    if !output.success {
15687        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15688    }
15689    Ok(GitDiff {
15690        mode: request.mode,
15691        path: request.path,
15692        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15693        truncated: output.truncated,
15694    })
15695}
15696
15697async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15698    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15699    ResponseEnvelope { request_id: envelope.request_id, result }
15700}
15701
15702async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15703    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15704        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15705    }
15706    let read_only = matches!(
15707        &request,
15708        NodeRequest::Snapshot
15709            | NodeRequest::Resync { .. }
15710            | NodeRequest::BrowseHostDirectories { .. }
15711            | NodeRequest::InspectWorkspace { .. }
15712            | NodeRequest::ReadWorkspaceFile { .. }
15713            | NodeRequest::ReadGitHistory { .. }
15714            | NodeRequest::ReadGitDiff { .. }
15715            | NodeRequest::CatalogNativeSessions { .. }
15716            | NodeRequest::PageNativeSessions { .. }
15717            | NodeRequest::PreviewNativeSession { .. }
15718            | NodeRequest::PreviewSessionRecord { .. }
15719    );
15720    let _mutation_guard = if read_only {
15721        None
15722    } else {
15723        let guard = shared.mutation_gate.lock().await;
15724        if shared.shutdown.load(Ordering::Acquire) {
15725            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15726        }
15727        Some(guard)
15728    };
15729    match request {
15730        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15731            event_sequence: shared.current_sequence(),
15732            controller: shared.controller_state(),
15733            snapshot: shared.snapshot(),
15734        }),
15735        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15736        NodeRequest::ArmHarnessMcpReservation {
15737            reservation_id,
15738            activation_digest,
15739            spawn_spec,
15740            launch,
15741            expires_at_unix_ms,
15742        } => {
15743            shared.require_controller(connection_id, role)?;
15744            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15745                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15746            })?;
15747            let expires_at_unix_ms = registry
15748                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15749                .await
15750                .map_err(harness_mcp_failure)?;
15751            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15752        }
15753        NodeRequest::SpawnSpecWithHarnessMcp {
15754            reservation_id,
15755            activation_digest,
15756            spec,
15757            deadline_unix_ms,
15758        } => {
15759            shared.require_controller(connection_id, role)?;
15760            let receipt = shared
15761                .spawn_from_spec_with_harness_mcp(
15762                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15763                )
15764                .await?;
15765            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15766        }
15767        NodeRequest::ActivateHarnessMcpReservation {
15768            reservation_id,
15769            activation_digest,
15770            record_id,
15771            session,
15772        } => {
15773            shared.require_controller(connection_id, role)?;
15774            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15775                current.instance_id == session.session.instance_id
15776                    && current.generation == session.session.generation
15777            }).and_then(|current| current.process_id).ok_or_else(|| {
15778                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15779            })?;
15780            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15781                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15782            })?.activate(
15783                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15784            ).map_err(harness_mcp_failure)?;
15785            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15786        }
15787        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15788            shared.require_controller(connection_id, role)?;
15789            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15790                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15791            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15792            if let Some(instance_id) = instance_id {
15793                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15794            }
15795            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15796        }
15797        NodeRequest::PutHarnessMcpReplyChunk {
15798            reservation_id, activation_digest, record_id, session, call_id,
15799            offset, final_chunk, chunk_hex,
15800        } => {
15801            shared.require_controller(connection_id, role)?;
15802            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15803                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15804            })?.put_reply_chunk(
15805                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15806                offset, final_chunk, &chunk_hex,
15807            ).map_err(harness_mcp_failure)?;
15808            Ok(NodeResponse::ReplyChunkAccepted {
15809                reservation_id, activation_digest, record_id, session, call_id,
15810                next_offset, completed,
15811            })
15812        }
15813        NodeRequest::RejectHarnessMcpCall {
15814            reservation_id, activation_digest, record_id, session, call_id, reason,
15815        } => {
15816            shared.require_controller(connection_id, role)?;
15817            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15818                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15819            })?.reject_call(
15820                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15821            ).map_err(harness_mcp_failure)?;
15822            Ok(NodeResponse::CallRejected {
15823                reservation_id, activation_digest, record_id, session, call_id,
15824            })
15825        }
15826        NodeRequest::BeginDeliveryStage { manifest } => {
15827            shared.require_controller(connection_id, role)?;
15828            shared.begin_delivery_stage(manifest)
15829        }
15830        NodeRequest::PutDeliveryBlobChunk {
15831            stage_id,
15832            blob_digest,
15833            offset,
15834            chunk_hex,
15835        } => {
15836            shared.require_controller(connection_id, role)?;
15837            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15838        }
15839        NodeRequest::CommitDeliveryStage { stage_id } => {
15840            shared.require_controller(connection_id, role)?;
15841            let receipt = shared.commit_delivery_stage(stage_id)?;
15842            Ok(NodeResponse::DeliveryCommitted { receipt })
15843        }
15844        NodeRequest::AbortDeliveryStage { stage_id } => {
15845            shared.require_controller(connection_id, role)?;
15846            shared.abort_delivery_stage(&stage_id)?;
15847            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15848        }
15849        NodeRequest::BrowseHostDirectories { directory, after } => {
15850            let listing = shared.browse_host_directories(directory, after).await?;
15851            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15852        }
15853        NodeRequest::InspectWorkspace { workspace_id } => {
15854            let inspection = shared.inspect_workspace(workspace_id).await?;
15855            Ok(NodeResponse::WorkspaceInspected { inspection })
15856        }
15857        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15858            let file = shared.read_workspace_file(workspace_id, path).await?;
15859            Ok(NodeResponse::WorkspaceFileRead { file })
15860        }
15861        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15862            shared.require_controller(connection_id, role)?;
15863            let file = shared
15864                .write_workspace_file(workspace_id, path, expected_revision, text)
15865                .await?;
15866            Ok(NodeResponse::WorkspaceFileWritten { file })
15867        }
15868        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15869            shared.require_controller(connection_id, role)?;
15870            let file = shared.create_workspace_file(workspace_id, path).await?;
15871            Ok(NodeResponse::WorkspaceFileCreated { file })
15872        }
15873        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15874            shared.require_controller(connection_id, role)?;
15875            let entry = shared
15876                .create_workspace_directory(workspace_id.clone(), path)
15877                .await?;
15878            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15879        }
15880        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15881            if role != ClientRole::Operator {
15882                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15883            }
15884            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15885                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15886            }
15887            let page = shared
15888                .read_git_history(workspace_id.clone(), path, before, limit)
15889                .await?;
15890            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15891        }
15892        NodeRequest::ReadGitDiff { workspace_id, request } => {
15893            if role != ClientRole::Operator {
15894                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15895            }
15896            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15897            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15898        }
15899        NodeRequest::CatalogNativeSessions { route, limit } => {
15900            if role != ClientRole::Operator {
15901                return Err(failure(
15902                    NodeFailureCode::ObserverReadOnly,
15903                    "native session catalog requires an operator connection",
15904                ));
15905            }
15906            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15907                return Err(failure(
15908                    NodeFailureCode::InvalidRequest,
15909                    "native session catalog limit is invalid",
15910                ));
15911            }
15912            let (entries, summary) = shared
15913                .catalog_native_sessions(route.clone(), limit)
15914                .await?;
15915            Ok(NodeResponse::NativeSessionsCataloged {
15916                route,
15917                entries,
15918                summary: Some(summary),
15919            })
15920        }
15921        NodeRequest::PageNativeSessions {
15922            route,
15923            window,
15924            catalog_revision,
15925            recent_cutoff_unix_ms,
15926            after_selection_id,
15927            limit,
15928        } => {
15929            if role != ClientRole::Operator {
15930                return Err(failure(
15931                    NodeFailureCode::ObserverReadOnly,
15932                    "native session catalog paging requires an operator connection",
15933                ));
15934            }
15935            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15936                || after_selection_id
15937                    .as_deref()
15938                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15939            {
15940                return Err(failure(
15941                    NodeFailureCode::InvalidRequest,
15942                    "native session catalog page request is invalid",
15943                ));
15944            }
15945            let page = shared
15946                .page_native_sessions(
15947                    route.clone(),
15948                    window,
15949                    catalog_revision,
15950                    recent_cutoff_unix_ms,
15951                    after_selection_id,
15952                    limit,
15953                )
15954                .await?;
15955            Ok(NodeResponse::NativeSessionsPaged {
15956                route,
15957                page,
15958            })
15959        }
15960        NodeRequest::PreviewNativeSession {
15961            selection,
15962            message_limit,
15963        } => {
15964            if role != ClientRole::Operator {
15965                return Err(failure(
15966                    NodeFailureCode::ObserverReadOnly,
15967                    "native session preview requires an operator connection",
15968                ));
15969            }
15970            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15971                .contains(&message_limit)
15972                || selection.validate().is_err()
15973            {
15974                return Err(failure(
15975                    NodeFailureCode::InvalidRequest,
15976                    "native session preview request is invalid",
15977                ));
15978            }
15979            let preview = shared
15980                .preview_native_session(selection.clone(), message_limit)
15981                .await?;
15982            Ok(NodeResponse::NativeSessionPreviewed {
15983                selection,
15984                preview,
15985            })
15986        }
15987        NodeRequest::IndexNativeSession {
15988            selection,
15989            display_name,
15990        } => {
15991            shared.require_controller(connection_id, role)?;
15992            let record = shared
15993                .index_native_session(selection.clone(), display_name)
15994                .await?;
15995            Ok(NodeResponse::NativeSessionIndexed { selection, record })
15996        }
15997        NodeRequest::PreviewSessionRecord {
15998            record_id,
15999            message_limit,
16000        } => {
16001            if role != ClientRole::Operator {
16002                return Err(failure(
16003                    NodeFailureCode::ObserverReadOnly,
16004                    "native session preview requires an operator connection",
16005                ));
16006            }
16007            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16008                .contains(&message_limit)
16009            {
16010                return Err(failure(
16011                    NodeFailureCode::InvalidRequest,
16012                    "native session preview request is invalid",
16013                ));
16014            }
16015            let record = shared.record(&record_id)?;
16016            let working_directory = shared.workspace_root(&record.workspace_id)?;
16017            if !platform::roots_equal(
16018                &working_directory,
16019                windows_path_text(&record.canonical_root),
16020            ) {
16021                return Err(failure(
16022                    NodeFailureCode::SessionRecordConflict,
16023                    "managed session workspace root changed; refusing to preview another directory",
16024                ));
16025            }
16026            let identity = record.provider_session.clone().ok_or_else(|| {
16027                failure(
16028                    NodeFailureCode::BackendOperationFailed,
16029                    "native session preview is unavailable for this session record",
16030                )
16031            })?;
16032            if identity.key != ProviderSessionKey::SessionId {
16033                return Err(failure(
16034                    NodeFailureCode::BackendOperationFailed,
16035                    "native session preview is unavailable for this session identity",
16036                ));
16037            }
16038            let preview = shared
16039                .preview_native_session_by_session_id(
16040                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16041                )
16042                .await?;
16043            if preview.session_id != identity.id {
16044                return Err(failure(
16045                    NodeFailureCode::SessionRecordConflict,
16046                    "native session preview identity changed while loading",
16047                ));
16048            }
16049            shared.revalidate_session_record_preview(&record, &identity)?;
16050            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16051            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16052                record_id: record_id.clone(),
16053                summary: SessionHistorySummaryV1::from(&preview),
16054            });
16055            Ok(NodeResponse::SessionRecordPreviewed {
16056                record_id,
16057                preview,
16058            })
16059        }
16060        NodeRequest::AcquireController { lease_ms } => {
16061            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16062            Ok(NodeResponse::Controller { controller: Some(controller) })
16063        }
16064        NodeRequest::ReleaseController => {
16065            shared.release_controller(connection_id);
16066            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16067        }
16068        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16069            shared.require_controller(connection_id, role)?;
16070            let root = require_windows_path(root)?;
16071            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16072            let workspace = shared.register_workspace(workspace_id, root).await?;
16073            Ok(NodeResponse::WorkspaceRegistered { workspace })
16074        }
16075        NodeRequest::CreateStandaloneWorkspace {
16076            workspace_id,
16077            root,
16078            initial_branch,
16079        } => {
16080            shared.require_controller(connection_id, role)?;
16081            let root = require_windows_path(root)?;
16082            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16083            let workspace = shared
16084                .create_standalone_workspace(workspace_id, root, initial_branch)
16085                .await?;
16086            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16087        }
16088        NodeRequest::UnregisterWorkspace { workspace_id } => {
16089            shared.require_controller(connection_id, role)?;
16090            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16091            shared.unregister_workspace(&workspace_id)?;
16092            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16093        }
16094        NodeRequest::CreateWorktree {
16095            source_workspace_id,
16096            workspace_id,
16097            target_root,
16098            branch,
16099            base,
16100        } => {
16101            shared.require_controller(connection_id, role)?;
16102            let target_root = require_windows_path(target_root)?;
16103            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16104            let (worktree, workspace) = shared
16105                .create_worktree(
16106                    source_workspace_id,
16107                    workspace_id,
16108                    target_root,
16109                    branch,
16110                    base,
16111                )
16112                .await?;
16113            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16114        }
16115        NodeRequest::RemoveWorktree {
16116            source_workspace_id,
16117            target_root,
16118        } => {
16119            shared.require_controller(connection_id, role)?;
16120            let native_target_root = require_windows_path(target_root.clone())?;
16121            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16122            let workspace_id = shared
16123                .remove_worktree(source_workspace_id, native_target_root)
16124                .await?;
16125            Ok(NodeResponse::WorktreeRemoved {
16126                target_root,
16127                workspace_id,
16128            })
16129        }
16130        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16131            shared.require_controller(connection_id, role)?;
16132            if !terminal_size.is_valid() {
16133                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16134            }
16135            if let Some(prompt) = initial_prompt.as_deref() {
16136                validate_node_text("spawn initial prompt", prompt)?;
16137            }
16138            match shared
16139                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16140                .await
16141            {
16142                Ok(session) => Ok(NodeResponse::SpawnAccepted { session }),
16143                Err(err) => {
16144                    tracing::warn!(
16145                        %provider,
16146                        %workspace_id,
16147                        ?mode,
16148                        code = ?err.code,
16149                        message = %err.message,
16150                        "session spawn refused"
16151                    );
16152                    Err(err)
16153                }
16154            }
16155        }
16156        NodeRequest::SpawnSpec { spec } => {
16157            shared.require_controller(connection_id, role)?;
16158            let receipt = shared.spawn_from_spec(spec).await?;
16159            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16160        }
16161        NodeRequest::SpawnManagedWorktree { request } => {
16162            shared.require_controller(connection_id, role)?;
16163            let receipt = shared.spawn_managed_worktree(request).await?;
16164            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16165        }
16166        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16167            shared.require_controller(connection_id, role)?;
16168            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16169            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16170        }
16171        NodeRequest::CleanupManagedWorktree { lease_id } => {
16172            shared.require_controller(connection_id, role)?;
16173            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16174            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16175        }
16176        NodeRequest::DiscoverHistory { session, limit } => {
16177            controlled_session(shared, connection_id, role, &session)?;
16178            let candidates = shared.discover_history(&session, limit).await?;
16179            Ok(NodeResponse::HistoryDiscovered {
16180                session,
16181                candidates,
16182            })
16183        }
16184        NodeRequest::LoadHistory {
16185            session,
16186            candidate_id,
16187        } => {
16188            controlled_session(shared, connection_id, role, &session)?;
16189            let loaded = shared
16190                .load_history(&session, candidate_id)
16191                .await?;
16192            Ok(NodeResponse::HistoryLoaded {
16193                session,
16194                session_id: loaded.session_id,
16195                message_count: loaded.message_count,
16196                completed_turn_count: loaded.completed_turn_count,
16197            })
16198        }
16199        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16200            shared.require_controller(connection_id, role)?;
16201            let context = shared
16202                .export_context_pack_for_session_record(&record_id, &session, false)
16203                .await?;
16204            Ok(NodeResponse::ContextPackForSessionRecordExported {
16205                record_id,
16206                session,
16207                context,
16208            })
16209        }
16210        NodeRequest::ExportContextPack { session } => {
16211            controlled_session(shared, connection_id, role, &session)?;
16212            let context = shared.export_context_pack(&session).await?;
16213            Ok(NodeResponse::ContextPackExported { context })
16214        }
16215        NodeRequest::ForgetContextPack { context_id } => {
16216            shared.require_controller(connection_id, role)?;
16217            shared.forget_context_pack(&context_id)?;
16218            Ok(NodeResponse::ContextPackForgotten { context_id })
16219        }
16220        NodeRequest::ResolveDurableContextPack { context_id } => {
16221            // Read-only, idempotent, no controller lease required: this lets
16222            // a caller preflight "is this durably exported pack still here"
16223            // without holding the Node's mutation controller.
16224            let context = shared
16225                .context_catalog
16226                .read()
16227                .unwrap_or_else(|poisoned| poisoned.into_inner())
16228                .get(&context_id)
16229                .map(|pack| pack.receipt().clone())
16230                .ok_or_else(|| {
16231                    failure(
16232                        NodeFailureCode::UnknownContextPack,
16233                        "context pack is not present",
16234                    )
16235                })?;
16236            Ok(NodeResponse::DurableContextPackResolved { context })
16237        }
16238        NodeRequest::ReadContextPack { digest } => {
16239            // Read-only fetch of the pack's own bytes, keyed by content digest
16240            // rather than the caller's `SpawnContextId`: same no-controller-lease
16241            // rationale as `ResolveDurableContextPack` above, one step further
16242            // down the same catalog.
16243            let (id, byte_len, bytes) = shared
16244                .context_catalog
16245                .read()
16246                .unwrap_or_else(|poisoned| poisoned.into_inner())
16247                .get_by_digest(&digest)
16248                .map(|pack| {
16249                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16250                })
16251                .ok_or_else(|| {
16252                    failure(
16253                        NodeFailureCode::UnknownContextPack,
16254                        &format!("context pack {digest} not on this node"),
16255                    )
16256                })?;
16257            tracing::debug!(
16258                digest = digest.as_str(),
16259                byte_len,
16260                "read context pack bytes",
16261            );
16262            Ok(NodeResponse::ContextPackBytesRead {
16263                pack: ContextPackBytesRead {
16264                    digest,
16265                    id,
16266                    byte_len,
16267                    bytes,
16268                },
16269            })
16270        }
16271        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16272            let provider = controlled_session(shared, connection_id, role, &session)?;
16273            if let Some(prompt) = initial_prompt.as_deref() {
16274                validate_node_text("resume initial prompt", prompt)?;
16275            }
16276            let runtime_requirement = if initial_prompt.is_some() {
16277                ProviderRuntimeRequirement::ResumeWithPrompt
16278            } else {
16279                ProviderRuntimeRequirement::Resume
16280            };
16281            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16282            let runtime_policy = shared
16283                .admit_provider_runtime(&provider, runtime_requirement)
16284                .await?;
16285            let working_directory = shared.bound_workspace_root(&session)?;
16286            let request = ResumeLaunchRequest {
16287                working_directory,
16288                terminal_size,
16289                initial_prompt,
16290            };
16291            request
16292                .validate()
16293                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16294            let command = shared.prepare_command(ControlCommand::Resume {
16295                instance_id: session.session.instance_id,
16296                target: ResumeTarget::CurrentProvider,
16297                runtime_policy,
16298                request,
16299            });
16300            shared.arm_resume(&session, command.id, runtime_policy)?;
16301            let dispatch = shared.dispatch_envelope(command);
16302            if let Err(error) = dispatch {
16303                shared.clear_armed_resume(&session);
16304                return Err(error);
16305            }
16306            Ok(NodeResponse::Accepted)
16307        }
16308        NodeRequest::RenameSessionRecord {
16309            record_id,
16310            display_name,
16311        } => {
16312            shared.require_controller(connection_id, role)?;
16313            let record = shared.rename_session_record(&record_id, display_name)?;
16314            Ok(NodeResponse::SessionRecordUpdated { record })
16315        }
16316        NodeRequest::SetSessionTask {
16317            record_id,
16318            expected_revision,
16319            target,
16320        } => {
16321            shared.require_controller(connection_id, role)?;
16322            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16323            Ok(NodeResponse::SessionRecordUpdated { record })
16324        }
16325        NodeRequest::IndexProviderSession {
16326            workspace_id,
16327            provider,
16328            identity,
16329            display_name,
16330        } => {
16331            shared.require_controller(connection_id, role)?;
16332            let record = shared.index_provider_session(
16333                workspace_id,
16334                provider,
16335                identity,
16336                display_name,
16337            )?;
16338            Ok(NodeResponse::ProviderSessionIndexed { record })
16339        }
16340        NodeRequest::ResumeSessionRecord {
16341            record_id,
16342            terminal_size,
16343            initial_prompt,
16344        } => {
16345            shared.require_controller(connection_id, role)?;
16346            if let Some(prompt) = initial_prompt.as_deref() {
16347                validate_node_text("managed resume initial prompt", prompt)?;
16348            }
16349            let (record, session) = shared
16350                .resume_session_record(
16351                    &record_id,
16352                    terminal_size,
16353                    initial_prompt,
16354                )
16355                .await?;
16356            Ok(NodeResponse::SessionRecordResumed { record, session })
16357        }
16358        NodeRequest::ForgetSessionRecord { record_id } => {
16359            shared.require_controller(connection_id, role)?;
16360            shared.forget_session_record(&record_id).await?;
16361            Ok(NodeResponse::SessionRecordForgotten { record_id })
16362        }
16363        NodeRequest::Prompt { session, text } => {
16364            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16365            validate_node_text("prompt", &text)?;
16366            shared.require_session_runtime_policy(
16367                &session,
16368                ProviderRuntimeRequirement::SemanticPrompt,
16369            )?;
16370            let framing = prompt_framing(&agent_id);
16371            shared
16372                .dispatch_input_bounded(
16373                    &session,
16374                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16375                )
16376                .await?;
16377            Ok(NodeResponse::Accepted)
16378        }
16379        NodeRequest::Paste { session, text } => {
16380            controlled_session(shared, connection_id, role, &session)?;
16381            validate_node_text("paste", &text)?;
16382            shared.require_session_runtime_policy(
16383                &session,
16384                ProviderRuntimeRequirement::SemanticPrompt,
16385            )?;
16386            shared
16387                .dispatch_input_bounded(
16388                    &session,
16389                    InputAction::InsertDraft(PromptPayload {
16390                        text,
16391                        framing: PromptFraming::BracketedPaste,
16392                    }),
16393                )
16394                .await?;
16395            Ok(NodeResponse::Accepted)
16396        }
16397        NodeRequest::Input { session, text } => {
16398            controlled_session(shared, connection_id, role, &session)?;
16399            validate_node_text("terminal input", &text)?;
16400            shared
16401                .dispatch_input_bounded(
16402                    &session,
16403                    InputAction::TerminalText(TerminalText { text }),
16404                )
16405                .await?;
16406            Ok(NodeResponse::Accepted)
16407        }
16408        NodeRequest::TerminalBytes { session, bytes } => {
16409            controlled_session(shared, connection_id, role, &session)?;
16410            let action = terminal_bytes_action(bytes)?;
16411            shared
16412                .dispatch_input_bounded(&session, action)
16413                .await?;
16414            Ok(NodeResponse::Accepted)
16415        }
16416        NodeRequest::TerminalControl { session, control } => {
16417            controlled_session(shared, connection_id, role, &session)?;
16418            shared
16419                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16420                .await?;
16421            Ok(NodeResponse::Accepted)
16422        }
16423        NodeRequest::Resize { session, size } => {
16424            controlled_session(shared, connection_id, role, &session)?;
16425            if !size.is_valid() {
16426                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16427            }
16428            shared.dispatch_resize_bounded(&session, size).await?;
16429            Ok(NodeResponse::Accepted)
16430        }
16431        NodeRequest::Interrupt { session } => {
16432            controlled_session(shared, connection_id, role, &session)?;
16433            shared
16434                .dispatch_input_bounded(
16435                    &session,
16436                    InputAction::TerminalControl(TerminalControl::Interrupt),
16437                )
16438                .await?;
16439            Ok(NodeResponse::Accepted)
16440        }
16441        NodeRequest::Stop { session, force } => {
16442            controlled_session(shared, connection_id, role, &session)?;
16443            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16444            Ok(NodeResponse::Accepted)
16445        }
16446        NodeRequest::Remove { session } => {
16447            controlled_session(shared, connection_id, role, &session)?;
16448            shared.remove_session(&session).await?;
16449            Ok(NodeResponse::Accepted)
16450        }
16451        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16452            shared.require_controller(connection_id, role)?;
16453            let snapshot = shared.require_acp_session(&session)?;
16454            let interaction = snapshot
16455                .provider
16456                .interactions
16457                .iter()
16458                .find(|interaction| {
16459                    interaction_correlation(
16460                        session.session.instance_id,
16461                        session.session.generation,
16462                        interaction.id.0,
16463                    ) == correlation_id
16464                })
16465                .ok_or_else(|| failure(
16466                    NodeFailureCode::InvalidRequest,
16467                    "unknown interaction correlation",
16468                ))?;
16469            if interaction.status != ProviderInteractionStatus::Pending {
16470                return Err(failure(
16471                    NodeFailureCode::BackendOperationFailed,
16472                    "interaction has already been resolved",
16473                ));
16474            }
16475            response
16476                .validate_for(interaction.interaction_kind)
16477                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16478            let interaction_id = interaction.id;
16479            let response_kind = response.kind();
16480            let response_outcome = response.outcome();
16481            shared.dispatch(ControlCommand::ResolveInteraction {
16482                instance_id: session.session.instance_id,
16483                generation: session.session.generation,
16484                interaction_id,
16485                response,
16486            })?;
16487            // The one line that lets an operator compare what THIS node
16488            // recorded against what the provider itself reports doing --
16489            // measured live on a kimi run where our side resolved `true`
16490            // while the agent reported "rejected" and neither layer had a
16491            // line to line up against the other.
16492            tracing::info!(
16493                node_id = %shared.node_id,
16494                session = ?session.session,
16495                correlation_id = %correlation_id,
16496                interaction_id = interaction_id.0,
16497                response_kind = ?response_kind,
16498                outcome = ?response_outcome,
16499                "interaction resolved",
16500            );
16501            Ok(NodeResponse::Accepted)
16502        }
16503        NodeRequest::SetSessionMode { session, mode_id } => {
16504            shared.require_controller(connection_id, role)?;
16505            shared.require_acp_session(&session)?;
16506            shared.dispatch(ControlCommand::SetSessionMode {
16507                instance_id: session.session.instance_id,
16508                mode_id,
16509            })?;
16510            Ok(NodeResponse::Accepted)
16511        }
16512        NodeRequest::SetSessionConfigOption {
16513            session,
16514            option_id,
16515            value_json,
16516        } => {
16517            shared.require_controller(connection_id, role)?;
16518            shared.require_acp_session(&session)?;
16519            shared.dispatch(ControlCommand::SetSessionConfigOption {
16520                instance_id: session.session.instance_id,
16521                option_id,
16522                value_json,
16523            })?;
16524            Ok(NodeResponse::Accepted)
16525        }
16526        NodeRequest::SetSessionModel { session, model_id } => {
16527            shared.require_controller(connection_id, role)?;
16528            shared.require_acp_session(&session)?;
16529            shared.dispatch(ControlCommand::SetSessionModel {
16530                instance_id: session.session.instance_id,
16531                model_id,
16532            })?;
16533            Ok(NodeResponse::Accepted)
16534        }
16535        NodeRequest::Shutdown => {
16536            shared.require_controller(connection_id, role)?;
16537            shared.begin_shutdown_locked().await?;
16538            Ok(NodeResponse::ShuttingDown)
16539        }
16540    }
16541}
16542
16543fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16544    shared.require_controller(connection_id, role)?;
16545    shared.validate_address(session)
16546}
16547
16548/// Selects the sole history candidate matching `provider_session_id` when
16549/// the managed record carries a verified identity, or the sole candidate in
16550/// the discovered set at all when it does not (see
16551/// `export_context_pack_for_session_record_inner`'s identity match). Either
16552/// way, more than one match refuses instead of guessing which one is exact.
16553fn select_history_candidate(
16554    candidates: &[HistoryCandidateSummary],
16555    provider_session_id: Option<&str>,
16556) -> Result<String, NodeFailure> {
16557    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16558        Some(id) => candidate.session_id_hint == id,
16559        None => true,
16560    });
16561    let candidate = matches.next().ok_or_else(|| {
16562        failure(
16563            NodeFailureCode::SessionRecordConflict,
16564            if provider_session_id.is_some() {
16565                "no history candidate matches the managed provider session"
16566            } else {
16567                "no history candidate exists for the managed session"
16568            },
16569        )
16570    })?;
16571    if matches.next().is_some() {
16572        return Err(failure(
16573            NodeFailureCode::SessionRecordConflict,
16574            if provider_session_id.is_some() {
16575                "history identity is ambiguous for the managed provider session"
16576            } else {
16577                "history is ambiguous without a verified provider session identity"
16578            },
16579        ));
16580    }
16581    Ok(candidate.id.clone())
16582}
16583
16584fn resolved_spawn_profile_summary(
16585    profile: &SpawnProfileDefaults,
16586    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16587) -> Option<SpawnProfileSummary> {
16588    let environment_profile = match profile.environment_profile_id.as_ref() {
16589        None => None,
16590        Some(profile_id) => {
16591            let binding = environment_profiles.get(profile_id)?;
16592            if binding.provider != profile.provider
16593                || binding.native_profile_id(profile.mode).is_none()
16594            {
16595                return None;
16596            }
16597            Some(ResolvedEnvironmentProfileReceipt {
16598                profile_id: binding.id.clone(),
16599                profile_revision: binding.revision.clone(),
16600                network_allowlist: None,
16601                browser_profile_id: None,
16602            })
16603        }
16604    };
16605    Some(SpawnProfileSummary {
16606        id: profile.profile_id.clone(),
16607        revision: profile.revision.clone(),
16608        environment_profile,
16609    })
16610}
16611
16612/// True if `current` is an acceptable version of `expected` at export
16613/// revalidation/commit time.
16614///
16615/// When `allow_clean_detachment` is `false` (the on-demand,
16616/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16617/// be byte-identical to `expected` and still actively bound to `session` —
16618/// the original, unchanged invariant.
16619///
16620/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16621/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16622/// export reacts to produces on its own: `state` Live -> Dormant,
16623/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16624/// advancing. Every other field — including `provider_session`, `context`,
16625/// `bundle`, `task_binding` — must still match exactly. A record that changed
16626/// in any other way (rebind, a different active session, an identity
16627/// conflict, capacity eviction) is rejected either way; this widens nothing
16628/// about which sessions are eligible, only how the record's own expected
16629/// detachment is tolerated mid-export.
16630fn session_record_export_target_matches(
16631    expected: &ManagedSessionRecord,
16632    current: &ManagedSessionRecord,
16633    session: &SessionAddress,
16634    allow_clean_detachment: bool,
16635) -> bool {
16636    if current == expected && current.active_session.as_ref() == Some(session) {
16637        return current.state == ManagedSessionState::Live;
16638    }
16639    allow_clean_detachment
16640        && current.state == ManagedSessionState::Dormant
16641        && current.active_session.is_none()
16642        && current.record_id == expected.record_id
16643        && current.display_name == expected.display_name
16644        && current.provider == expected.provider
16645        && current.mode == expected.mode
16646        && current.workspace_id == expected.workspace_id
16647        && current.canonical_root == expected.canonical_root
16648        && current.provider_session == expected.provider_session
16649        && current.environment_profile == expected.environment_profile
16650        && current.bundle == expected.bundle
16651        && current.context_id == expected.context_id
16652        && current.context == expected.context
16653        && current.exported_context == expected.exported_context
16654        && current.task_binding == expected.task_binding
16655        && current.created_at_unix_ms == expected.created_at_unix_ms
16656        && current.last_error == expected.last_error
16657}
16658
16659fn session_record_context_export_binding_is_exact(
16660    expected: &ManagedSessionRecord,
16661    current: &ManagedSessionRecord,
16662    identity: Option<&ProviderSessionIdentity>,
16663    session: &SessionAddress,
16664    binding: &SessionBinding,
16665    provider: &AgentId,
16666    current_root: &str,
16667    allow_clean_detachment: bool,
16668) -> bool {
16669    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16670        && current.provider_session.as_ref() == identity
16671        && current.workspace_id == session.workspace_id
16672        && provider == &current.provider
16673        && binding.workspace_id == session.workspace_id
16674        && binding.generation == session.session.generation
16675        && binding.record_id.as_ref() == Some(&current.record_id)
16676        && platform::roots_equal(
16677            current_root,
16678            windows_path_text(&current.canonical_root),
16679        )
16680}
16681
16682fn session_record_context_export_source_is_usable(
16683    record: &ManagedSessionRecord,
16684    status: &SessionStatus,
16685    allow_clean_detachment: bool,
16686) -> bool {
16687    (record.state == ManagedSessionState::Live
16688        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16689        && context_pack_source_status_is_usable(status)
16690}
16691
16692fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16693    PromptFraming::BracketedPaste
16694}
16695
16696fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16697    if text.len() > MAX_NODE_TEXT_BYTES {
16698        return Err(failure(
16699            NodeFailureCode::InvalidRequest,
16700            &format!(
16701                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16702                text.len(),
16703            ),
16704        ));
16705    }
16706    Ok(())
16707}
16708
16709fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16710    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16711        return Err(failure(
16712            NodeFailureCode::InvalidRequest,
16713            &format!(
16714                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16715                bytes.len(),
16716            ),
16717        ));
16718    }
16719    Ok(())
16720}
16721
16722fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16723    validate_terminal_bytes(&bytes)?;
16724    Ok(InputAction::TerminalBytes(bytes))
16725}
16726
16727fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16728    if root.is_empty()
16729        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16730        || root.chars().any(char::is_control)
16731    {
16732        return Err(failure(
16733            NodeFailureCode::InvalidWorkspaceRoot,
16734            &format!(
16735                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16736            ),
16737        ));
16738    }
16739    Ok(())
16740}
16741
16742fn canonical_standalone_workspace_candidate(
16743    workspace_id: &WorkspaceId,
16744    root: &str,
16745) -> Result<String, NodeServerError> {
16746    let path = Path::new(root);
16747    match std::fs::symlink_metadata(path) {
16748        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16749            .map(|workspace| workspace.canonical_root),
16750        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16751            if !path.is_absolute() {
16752                return Err(NodeServerError::InvalidWorkspaceRoot {
16753                    workspace_id: workspace_id.clone(),
16754                    path: root.to_owned(),
16755                    message: "path is not absolute".to_owned(),
16756                });
16757            }
16758            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16759                workspace_id: workspace_id.clone(),
16760                path: root.to_owned(),
16761                message: "path has no standalone directory name".to_owned(),
16762            })?;
16763            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16764                workspace_id: workspace_id.clone(),
16765                path: root.to_owned(),
16766                message: "path has no parent directory".to_owned(),
16767            })?;
16768            let canonical = std::fs::canonicalize(parent)
16769                .map(|parent| parent.join(name))
16770                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16771                    workspace_id: workspace_id.clone(),
16772                    path: root.to_owned(),
16773                    message: error.to_string(),
16774                })?;
16775            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16776                NodeServerError::InvalidWorkspaceRoot {
16777                    workspace_id: workspace_id.clone(),
16778                    path: path.to_string_lossy().into_owned(),
16779                    message: "canonical path is not valid Unicode".to_owned(),
16780                }
16781            })?;
16782            let canonical_root = platform::normalize_canonical_root(canonical_root);
16783            validate_workspace_root(workspace_id, &canonical_root)?;
16784            Ok(canonical_root)
16785        }
16786        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16787            workspace_id: workspace_id.clone(),
16788            path: root.to_owned(),
16789            message: error.to_string(),
16790        }),
16791    }
16792}
16793
16794fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16795    let Some(revision) = revision else {
16796        return Ok(());
16797    };
16798    if revision.is_empty()
16799        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16800        || revision.chars().any(char::is_control)
16801    {
16802        return Err(failure(
16803            NodeFailureCode::InvalidRequest,
16804            &format!(
16805                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16806            ),
16807        ));
16808    }
16809    Ok(())
16810}
16811
16812fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16813    let code = match error.kind {
16814        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16815        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16816        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16817        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16818        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16819        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16820        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16821    };
16822    failure(code, &error.message)
16823}
16824
16825fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16826    let code = match error.kind {
16827        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16828        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16829        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16830            NodeFailureCode::BackendOperationFailed
16831        }
16832        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16833            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16834        }
16835    };
16836    failure(code, error.message)
16837}
16838
16839fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16840    let (code, message) = match error.kind {
16841        GitWorktreeErrorKind::Invalid => (
16842            NodeFailureCode::InvalidRequest,
16843            "managed Git input is invalid",
16844        ),
16845        GitWorktreeErrorKind::NotRepository => (
16846            NodeFailureCode::NotGitRepository,
16847            "managed source is not a Git repository",
16848        ),
16849        GitWorktreeErrorKind::Conflict => (
16850            NodeFailureCode::WorktreeConflict,
16851            "managed Git identity conflicts with existing repository state",
16852        ),
16853        GitWorktreeErrorKind::Protected => (
16854            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16855            "managed worktree ownership validation failed",
16856        ),
16857        GitWorktreeErrorKind::Dirty => (
16858            NodeFailureCode::WorktreeDirty,
16859            "managed worktree contains uncommitted changes",
16860        ),
16861        GitWorktreeErrorKind::Locked => (
16862            NodeFailureCode::WorktreeLocked,
16863            "managed worktree is locked",
16864        ),
16865        GitWorktreeErrorKind::Failed => (
16866            NodeFailureCode::BackendOperationFailed,
16867            "managed Git operation failed",
16868        ),
16869    };
16870    NodeFailure { code, message: message.to_owned() }
16871}
16872
16873fn managed_spawn_receipt(
16874    resolved: &ResolvedSpawnSpec,
16875    incarnation_id: NodeIncarnationId,
16876    session: SessionAddress,
16877    lease: ManagedWorktreeLeaseSnapshot,
16878    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16879    bundle: Option<ResolvedBundleReceipt>,
16880    context: Option<ResolvedContextPackReceipt>,
16881) -> ManagedWorktreeSpawnReceipt {
16882    let mut managed = resolved.clone();
16883    managed.target.worktree_id = Some(lease.workspace_id.clone());
16884    ManagedWorktreeSpawnReceipt {
16885        spawn: managed.receipt_with_materialization(
16886            incarnation_id,
16887            session,
16888            environment_profile,
16889            bundle,
16890            context,
16891        ),
16892        lease,
16893    }
16894}
16895
16896fn validate_selected_worktree(
16897    source_root: &str,
16898    selected_root: &str,
16899    worktrees: &[NativeGitWorktreeSnapshot],
16900) -> Result<(), NodeFailure> {
16901    if !worktrees
16902        .iter()
16903        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16904    {
16905        return Err(failure(
16906            NodeFailureCode::WorktreeProtected,
16907            "source workspace is not an authoritative non-bare Git worktree root",
16908        ));
16909    }
16910    if !worktrees.iter().any(|worktree| {
16911        worktree_paths_equal(&worktree.path, selected_root)
16912            && !worktree.is_main
16913            && !worktree.is_bare
16914    }) {
16915        return Err(failure(
16916            NodeFailureCode::WorktreeProtected,
16917            "selected workspace is not an eligible Git worktree in the source repository",
16918        ));
16919    }
16920    Ok(())
16921}
16922
16923fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16924    let code = match error.kind() {
16925        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16926        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16927            NodeFailureCode::RepositoryPathUnsafe
16928        }
16929        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16930        WorkspaceFileReadErrorKind::NotRegularFile => {
16931            NodeFailureCode::RepositoryFileNotRegular
16932        }
16933        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16934            NodeFailureCode::RepositoryFileReadFailed
16935        }
16936        WorkspaceFileReadErrorKind::RevisionConflict => {
16937            NodeFailureCode::RepositoryFileRevisionConflict
16938        }
16939        WorkspaceFileReadErrorKind::AlreadyExists
16940        | WorkspaceFileReadErrorKind::ParentNotFound
16941        | WorkspaceFileReadErrorKind::ParentNotDirectory
16942        | WorkspaceFileReadErrorKind::Canceled => {
16943            NodeFailureCode::RepositoryFileReadFailed
16944        }
16945    };
16946    failure(code, "repository file read failed")
16947}
16948
16949fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16950    let code = match error.kind() {
16951        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16952        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16953            NodeFailureCode::RepositoryPathUnsafe
16954        }
16955        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16956        WorkspaceFileReadErrorKind::NotRegularFile => {
16957            NodeFailureCode::RepositoryFileNotRegular
16958        }
16959        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16960            NodeFailureCode::RepositoryFileWriteFailed
16961        }
16962        WorkspaceFileReadErrorKind::RevisionConflict => {
16963            NodeFailureCode::RepositoryFileRevisionConflict
16964        }
16965        WorkspaceFileReadErrorKind::AlreadyExists
16966        | WorkspaceFileReadErrorKind::ParentNotFound
16967        | WorkspaceFileReadErrorKind::ParentNotDirectory
16968        | WorkspaceFileReadErrorKind::Canceled => {
16969            NodeFailureCode::RepositoryFileWriteFailed
16970        }
16971    };
16972    failure(code, "repository file write failed")
16973}
16974
16975async fn settle_workspace_entry_create<T>(
16976    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16977    commit_state: Arc<AtomicU8>,
16978    deadline: Duration,
16979    timeout_message: &'static str,
16980    task_failure_message: &'static str,
16981) -> Result<T, NodeFailure> {
16982    let joined = match timeout(deadline, &mut task).await {
16983        Ok(joined) => joined,
16984        Err(_) => {
16985            if commit_state
16986                .compare_exchange(
16987                    WORKSPACE_ENTRY_CREATE_PENDING,
16988                    WORKSPACE_ENTRY_CREATE_CANCELED,
16989                    Ordering::AcqRel,
16990                    Ordering::Acquire,
16991                )
16992                .is_ok()
16993            {
16994                return Err(failure(
16995                    NodeFailureCode::RepositoryEntryCreateTimedOut,
16996                    timeout_message,
16997                ));
16998            }
16999            debug_assert_eq!(
17000                commit_state.load(Ordering::Acquire),
17001                WORKSPACE_ENTRY_CREATE_COMMITTING,
17002            );
17003            task.await
17004        }
17005    };
17006    joined
17007        .map_err(|_| {
17008            failure(
17009                NodeFailureCode::RepositoryEntryCreateFailed,
17010                task_failure_message,
17011            )
17012        })?
17013        .map_err(workspace_entry_create_failure)
17014}
17015
17016fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17017    let code = match error.kind() {
17018        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17019        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17020            NodeFailureCode::RepositoryPathUnsafe
17021        }
17022        WorkspaceFileReadErrorKind::AlreadyExists => {
17023            NodeFailureCode::RepositoryEntryAlreadyExists
17024        }
17025        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17026            NodeFailureCode::RepositoryParentNotFound
17027        }
17028        WorkspaceFileReadErrorKind::ParentNotDirectory
17029        | WorkspaceFileReadErrorKind::NotRegularFile => {
17030            NodeFailureCode::RepositoryParentNotDirectory
17031        }
17032        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17033            NodeFailureCode::RepositoryEntryCreateFailed
17034        }
17035        WorkspaceFileReadErrorKind::RevisionConflict => {
17036            NodeFailureCode::RepositoryEntryCreateFailed
17037        }
17038        WorkspaceFileReadErrorKind::Canceled => {
17039            NodeFailureCode::RepositoryEntryCreateTimedOut
17040        }
17041    };
17042    failure(code, "repository entry create failed")
17043}
17044
17045fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17046    let value = digest(&SHA256, bytes)
17047        .as_ref()
17048        .iter()
17049        .map(|byte| format!("{byte:02x}"))
17050        .collect();
17051    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17052}
17053
17054fn managed_spawn_request_digest_v2(
17055    request: &ManagedWorktreeSpawnRequestV2,
17056) -> Result<String, NodeFailure> {
17057    let encoded = serde_json::to_vec(request).map_err(|_| {
17058        failure(
17059            NodeFailureCode::InvalidRequest,
17060            "managed worktree V2 request could not be canonicalized",
17061        )
17062    })?;
17063    let mut value = String::with_capacity(71);
17064    value.push_str("sha256:");
17065    for byte in digest(&SHA256, &encoded).as_ref() {
17066        use std::fmt::Write as _;
17067        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17068    }
17069    Ok(value)
17070}
17071
17072fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17073    let code = match error {
17074        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17075        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17076        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17077        NativeSessionPreviewError::UnsupportedProvider
17078        | NativeSessionPreviewError::PreviewUnavailable
17079        | NativeSessionPreviewError::SessionNotFound
17080        | NativeSessionPreviewError::AmbiguousSession => {
17081            NodeFailureCode::BackendOperationFailed
17082        }
17083    };
17084    failure(code, "native session preview failed")
17085}
17086
17087fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17088    let code = match error.kind() {
17089        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17090        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17091    };
17092    failure(code, "host directory browse failed")
17093}
17094
17095fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17096    let code = match error {
17097        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17098        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17099        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17100            NodeFailureCode::DeliveryStageConflict
17101        }
17102        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17103        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17104            NodeFailureCode::DeliveryChunkOutOfOrder
17105        }
17106        DeliveryStoreError::BlobDigestMismatch => {
17107            NodeFailureCode::DeliveryBlobDigestMismatch
17108        }
17109        DeliveryStoreError::BundleDigestMismatch => {
17110            NodeFailureCode::DeliveryBundleDigestMismatch
17111        }
17112        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17113        DeliveryStoreError::Unavailable
17114        | DeliveryStoreError::Corrupt
17115        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17116    };
17117    failure(code, "delivery operation failed")
17118}
17119
17120fn validate_workspace_root(
17121    workspace_id: &WorkspaceId,
17122    canonical_root: &str,
17123) -> Result<(), NodeServerError> {
17124    #[cfg(windows)]
17125    if canonical_root.starts_with(r"\\") {
17126        return Err(NodeServerError::InvalidWorkspaceRoot {
17127            workspace_id: workspace_id.clone(),
17128            path: canonical_root.to_owned(),
17129            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17130        });
17131    }
17132    if !platform::workspace_root_supported(canonical_root)
17133        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17134    {
17135        return Err(NodeServerError::InvalidWorkspaceRoot {
17136            workspace_id: workspace_id.clone(),
17137            path: canonical_root.to_owned(),
17138            message: "canonical path must be absolute, bounded, and free of control characters"
17139                .to_owned(),
17140        });
17141    }
17142    Ok(())
17143}
17144
17145fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17146    NodeFailure {
17147        code,
17148        message: node_failure_category(code).to_owned(),
17149    }
17150}
17151
17152fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17153    let code = match error {
17154        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17155        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17156        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17157        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17158        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17159        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17160        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17161        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17162        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17163    };
17164    failure(code, "harness MCP proxy operation failed")
17165}
17166
17167/// Numbers each harness-MCP overlay this process constructs, in
17168/// construction order, so two overlays built back to back in one node process
17169/// still get distinct trace files.
17170static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17171
17172/// Builds the optional trace environment entry of one harness-MCP overlay.
17173/// The caller's launch description names both the entry and the node-process
17174/// variable that opts in: when that variable holds a non-empty directory, the
17175/// entry names a trace file under it. Returns `None` when the launch has no
17176/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17177/// the directory; that is the operator's job.
17178fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17179    let trace = launch.trace.as_ref()?;
17180    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17181    if trace_dir.is_empty() {
17182        return None;
17183    }
17184    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17185    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17186    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17187}
17188
17189fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17190    let remaining = deadline.saturating_duration_since(Instant::now());
17191    if remaining.is_zero() {
17192        Err(failure(
17193            NodeFailureCode::SpawnDeadlineExceeded,
17194            "spawn deadline elapsed",
17195        ))
17196    } else {
17197        Ok(remaining)
17198    }
17199}
17200
17201fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17202    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17203    match deadline {
17204        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17205        None => Ok(bounded),
17206    }
17207}
17208
17209fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17210    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17211        failure(
17212            NodeFailureCode::SpawnDeadlineExceeded,
17213            "spawn dispatch exceeded the spawn deadline",
17214        )
17215    } else {
17216        error
17217    }
17218}
17219
17220fn spawn_runtime_capabilities(
17221    capabilities: &SpawnRequiredCapabilities,
17222) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17223    capabilities
17224        .iter()
17225        .map(|capability| match capability.as_str() {
17226            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17227            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17228            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17229            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17230                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17231            }
17232            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17233            _ => Err(failure(
17234                NodeFailureCode::UnsupportedSpawnCapability,
17235                "spawn required capability is unknown",
17236            )),
17237        })
17238        .collect()
17239}
17240
17241fn persistence_failure(_error: io::Error) -> NodeFailure {
17242    NodeFailure {
17243        code: NodeFailureCode::BackendOperationFailed,
17244        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17245    }
17246}
17247
17248fn node_failure_category(code: NodeFailureCode) -> &'static str {
17249    match code {
17250        NodeFailureCode::InvalidRequest => "invalid-request",
17251        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17252        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17253        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17254        NodeFailureCode::ReservationConflict => "reservation-conflict",
17255        NodeFailureCode::ReservationExpired => "reservation-expired",
17256        NodeFailureCode::BindingMismatch => "binding-mismatch",
17257        NodeFailureCode::NotActivated => "not-activated",
17258        NodeFailureCode::CallNotFound => "call-not-found",
17259        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17260        NodeFailureCode::ResponseTooLarge => "response-too-large",
17261        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17262        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17263        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17264        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17265        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17266        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17267        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17268        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17269        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17270        NodeFailureCode::Unauthorized => "unauthorized",
17271        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17272        NodeFailureCode::ControllerBusy => "controller-busy",
17273        NodeFailureCode::ControllerRequired => "controller-required",
17274        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17275        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17276        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17277        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17278        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17279        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17280        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17281        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17282        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17283        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17284        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17285        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17286        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17287        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17288        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17289        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17290        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17291        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17292        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17293        NodeFailureCode::GitReadFailed => "git-read-failed",
17294        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17295        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17296        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17297        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17298        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17299        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17300        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17301        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17302        NodeFailureCode::TurnInFlight => "turn-in-flight",
17303        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17304        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17305        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17306            "unsupported-network-allowlist-mapping"
17307        }
17308        NodeFailureCode::BrowserStationProbeUnavailable => {
17309            "browser-station-probe-unavailable"
17310        }
17311        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17312        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17313        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17314            "environment-profile-binding-mismatch"
17315        }
17316        NodeFailureCode::UnknownBundle => "unknown-bundle",
17317        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17318        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17319        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17320        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17321        NodeFailureCode::ContextPackMaterializationFailed => {
17322            "context-pack-materialization-failed"
17323        }
17324        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17325        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17326        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17327        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17328        NodeFailureCode::LastWorkspace => "last-workspace",
17329        NodeFailureCode::NotGitRepository => "not-git-repository",
17330        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17331        NodeFailureCode::WorktreeProtected => "worktree-protected",
17332        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17333        NodeFailureCode::WorktreeLocked => "worktree-locked",
17334        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17335        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17336        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17337        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17338            "managed-worktree-profile-revision-mismatch"
17339        }
17340        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17341        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17342            "standalone-workspace-recovery-required"
17343        }
17344        NodeFailureCode::UnknownSession => "unknown-session",
17345        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17346        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17347        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17348        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17349        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17350        NodeFailureCode::WorkspaceRegistrationRequired => {
17351            "workspace-registration-required"
17352        }
17353        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17354        NodeFailureCode::StaleGeneration => "stale-generation",
17355        NodeFailureCode::BackendBusy => "backend-busy",
17356        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17357        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17358        NodeFailureCode::ShuttingDown => "shutting-down",
17359    }
17360}
17361
17362fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17363    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17364}
17365
17366fn durable_state_load_error(error: io::Error) -> NodeServerError {
17367    let category = match session_registry::state_load_refusal(&error) {
17368        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17369            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17370        }
17371        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17372            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17373        }
17374        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17375            DURABLE_STATE_CONFLICT_ERROR
17376        }
17377        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17378    };
17379    durable_state_server_error(error, category)
17380}
17381
17382fn unix_time_ms() -> u64 {
17383    std::time::SystemTime::now()
17384        .duration_since(std::time::UNIX_EPOCH)
17385        .unwrap_or_default()
17386        .as_millis()
17387        .min(u64::MAX as u128) as u64
17388}
17389
17390fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17391    if record.provider_session.is_some() {
17392        ManagedSessionState::Dormant
17393    } else {
17394        ManagedSessionState::Unavailable
17395    }
17396}
17397
17398fn active_record_state(
17399    identity_admitted: bool,
17400    provider_session_is_known: bool,
17401) -> ManagedSessionState {
17402    if identity_admitted && !provider_session_is_known {
17403        ManagedSessionState::IdentityPending
17404    } else {
17405        ManagedSessionState::Live
17406    }
17407}
17408
17409fn managed_worktree_record_holders(
17410    records: &[ManagedSessionRecord],
17411) -> Vec<(SessionRecordId, WorkspaceId)> {
17412    records
17413        .iter()
17414        .filter(|record| record.provider_session.is_some())
17415        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17416        .collect()
17417}
17418
17419fn managed_spawn_record_ownership(
17420    binding: &SessionBinding,
17421    effective_runtime_policy: ProviderRuntimePolicy,
17422) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17423    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17424    if effective_runtime_policy.provider_session_identity {
17425        (binding.record_id.clone(), None)
17426    } else {
17427        (None, binding.record_id.clone())
17428    }
17429}
17430
17431#[derive(Debug, Error)]
17432pub enum NodeServerError {
17433    #[error("node endpoint must be a bounded absolute local transport path")]
17434    InvalidEndpoint,
17435    #[error("node access token must contain 1..=4096 bytes")]
17436    InvalidAccessToken,
17437    #[error("node HTTP observer must listen on a loopback address: {0}")]
17438    InvalidApiListen(std::net::SocketAddr),
17439    #[error("node browser bridge must listen on a loopback address: {0}")]
17440    InvalidBridgeListen(std::net::SocketAddr),
17441    #[error("node browser bridge token must contain 1..=4096 bytes")]
17442    InvalidBridgeToken,
17443    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17444    BridgeUnderlayRequiresBridgeListen,
17445    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17446    BridgeUnderlayRequiresBridgeToken,
17447    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17448    InvalidBridgeUnderlayToken,
17449    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17450    InvalidBridgeUnderlayKey,
17451    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17452    InvalidCallHome(std::net::SocketAddr),
17453    #[error("node could not reach its call-home relay: {0}")]
17454    CallHomeTransport(#[source] std::io::Error),
17455    #[error("node durable state path must be an absolute file path: {0}")]
17456    InvalidStatePath(String),
17457    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17458    InvalidHarnessMcpHelper,
17459    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17460    InvalidNetworkAllowlistCatalogPath(String),
17461    #[error("network allowlist catalog could not be read: {0}")]
17462    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17463    #[error("network allowlist catalog line {line}: {message}")]
17464    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17465    #[error("network allowlist catalog: {0}")]
17466    InvalidNetworkAllowlistCatalog(String),
17467    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17468    NetworkAllowlistCatalogCapacity { max: usize },
17469    #[error("the local state directory is unavailable; supply an explicit state path")]
17470    LocalStateDirectoryUnavailable,
17471    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17472    LocalRuntimeDirectoryUnavailable,
17473    #[error("node durable state failed: {0}")]
17474    DurableState(io::Error),
17475    #[error("node requires at least one configured workspace")]
17476    NoWorkspaces,
17477    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17478    InvalidWorkspaceRoot {
17479        workspace_id: WorkspaceId,
17480        path: String,
17481        message: String,
17482    },
17483    #[error("duplicate workspace ID: {0}")]
17484    DuplicateWorkspaceId(WorkspaceId),
17485    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17486    DuplicateWorkspaceRoot {
17487        first: WorkspaceId,
17488        second: WorkspaceId,
17489        root: String,
17490    },
17491    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17492    ManagedWorktreeProfileRequired(WorkspaceId),
17493    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17494    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17495    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17496    DuplicateManagedWorktreeProfile {
17497        workspace_id: WorkspaceId,
17498        profile_id: WorktreeProfileId,
17499    },
17500    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17501    ManagedWorktreeProfileCapacity {
17502        workspace_id: WorkspaceId,
17503        max: usize,
17504    },
17505    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17506    InvalidManagedWorktreeProfile {
17507        workspace_id: WorkspaceId,
17508        profile_id: WorktreeProfileId,
17509        message: String,
17510    },
17511    #[error("duplicate node environment profile: {0}")]
17512    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17513    #[error("node environment profile provider is unavailable: {0}")]
17514    EnvironmentProfileProviderUnavailable(AgentId),
17515    #[error("node environment profile capacity is {max}")]
17516    EnvironmentProfileCapacity { max: usize },
17517    #[error("node environment profiles reuse native profile ID '{0}'")]
17518    DuplicateNativeEnvironmentProfile(String),
17519    #[error("node native environment profile is invalid: {0}")]
17520    NativeEnvironmentProfile(String),
17521    #[error("node session-environment materialization root must be absolute")]
17522    InvalidSessionEnvironmentRoot,
17523    #[error("node session-environment materializer is unavailable")]
17524    SessionEnvironmentMaterializerRequired,
17525    #[error("node session-environment materializer failed to initialize")]
17526    SessionEnvironmentMaterializer,
17527    #[error("node bundle catalog is invalid: {0}")]
17528    BundleCatalog(String),
17529    #[error("node delivery store failed to initialize: {0}")]
17530    DeliveryStore(String),
17531    #[error("node context pack store failed to initialize")]
17532    ContextPackStore,
17533    #[error("active agent registry failed: {0}")]
17534    Registry(String),
17535    #[error("node provider contract manifest is invalid: {0}")]
17536    ProviderContractManifest(String),
17537    #[error("named pipe I/O failed: {0}")]
17538    Io(#[from] io::Error),
17539    #[error("node HTTP observer failed: {0}")]
17540    HttpApi(io::Error),
17541    #[error("node browser bridge failed: {0}")]
17542    BridgeApi(io::Error),
17543    #[error(transparent)]
17544    Frame(#[from] FrameError),
17545    #[error("node handshake failed: {0}")]
17546    Handshake(String),
17547    #[error("node authentication frame was not received before the bounded deadline")]
17548    AuthenticationTimedOut,
17549    #[error("node authentication primitive failed: {0}")]
17550    Authentication(String),
17551    #[error("node incarnation identity generation failed: {0}")]
17552    IncarnationIdentity(String),
17553    #[error("node pre-authentication limiter is closed")]
17554    PreauthClosed,
17555    #[error("node authenticated connection limit was reached")]
17556    AuthenticatedConnectionLimit,
17557    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17558    SignalStreamClosed,
17559    #[error("node stop-all dispatch failed: {0:?}")]
17560    ShutdownDispatch(NodeFailure),
17561    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17562    ShutdownTimedOut { active_native_sessions: usize },
17563}
17564
17565impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17566    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17567        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17568        match error {
17569            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17570            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17571            E::InvalidEntry { line, message } => {
17572                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17573            }
17574            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17575            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17576        }
17577    }
17578}
17579
17580#[cfg(test)]
17581mod agent_stream_blocked_tests {
17582    use super::*;
17583    use gate4agent_types::{
17584        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17585        ProviderSource,
17586    };
17587
17588    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17589        ProviderSource {
17590            family,
17591            binding: AdapterBinding::new(
17592                AdapterId::new(adapter).unwrap(),
17593                "fixture/v1",
17594                AdapterVerification::SyntheticFixture,
17595            )
17596            .unwrap(),
17597        }
17598    }
17599
17600    fn provider_control_event_at(
17601        family: AdapterFamily,
17602        adapter: &str,
17603        source_sequence: u64,
17604        provider_event: ProviderEvent,
17605    ) -> ControlEvent {
17606        ControlEvent {
17607            sequence: 41,
17608            command_id: None,
17609            instance_id: AgentInstanceId(7),
17610            generation: SessionGeneration(3),
17611            event: ControlEventKind::ProviderEvent {
17612                sequence: 8,
17613                source: provider_source(family, adapter),
17614                source_sequence,
17615                event: provider_event,
17616            },
17617        }
17618    }
17619
17620    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17621        provider_control_event_at(family, "fixture", 9, provider_event)
17622    }
17623
17624    fn denied_host_request(
17625        by: ProviderHostDecisionAuthority,
17626        reason: Option<String>,
17627    ) -> ControlEvent {
17628        provider_control_event_at(
17629            AdapterFamily::Acp,
17630            "claude-code",
17631            2,
17632            ProviderEvent::HostRequestObserved {
17633                method: "terminal/create".to_owned(),
17634                params_json: String::new(),
17635                decision: ProviderHostRequestDecision::Denied { by },
17636                outcome: ProviderHostRequestOutcome::Executed,
17637                reason,
17638            },
17639        )
17640    }
17641
17642    fn blocked(
17643        event: &ControlEvent,
17644    ) -> (
17645        Option<String>,
17646        String,
17647        BlockAuthorityV1,
17648        Option<String>,
17649        String,
17650        Option<String>,
17651    ) {
17652        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17653        chunk.validate().expect("minted chunk must validate");
17654        let AgentStreamChunkKindV1::Blocked {
17655            correlation_id,
17656            tool_class,
17657            authority,
17658            reason_kind,
17659            reason,
17660            help,
17661        } = chunk.kind
17662        else {
17663            panic!("expected AgentStreamChunkKindV1::Blocked");
17664        };
17665        (correlation_id, tool_class, authority, reason_kind, reason, help)
17666    }
17667
17668    /// Pins the fleet and every adapter each member declares: the fleet is
17669    /// exactly what `active_registry` says, and no member declares a hook
17670    /// adapter (lifecycle hooks are retired).
17671    #[test]
17672    fn the_enabled_provider_matrix_is_exact() {
17673        let registry = active_registry().unwrap();
17674        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17675        assert_eq!(
17676            providers
17677                .iter()
17678                .map(|contract| contract.provider.as_str())
17679                .collect::<Vec<_>>(),
17680            vec!["claude", "codex", "grok", "kimi"],
17681        );
17682        assert!(adapters.iter().all(|contract| {
17683            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17684        }));
17685    }
17686
17687    /// A gate-denied host request, WITH its reason text carried all the way
17688    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17689    #[test]
17690    fn gate_denied_host_request_mints_a_blocked_chunk() {
17691        let gate_text =
17692            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17693        let event = denied_host_request(
17694            ProviderHostDecisionAuthority::Gate,
17695            Some(gate_text.to_owned()),
17696        );
17697        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17698        assert_eq!(chunk.source_sequence, 2);
17699        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17700        assert_eq!(authority, BlockAuthorityV1::HostGate);
17701        assert_eq!(reason, gate_text);
17702        assert_eq!(correlation_id, None);
17703        assert_eq!(reason_kind, None);
17704        assert_eq!(help, None);
17705        assert_eq!(tool_class, "Write");
17706    }
17707
17708    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.";
17709
17710    /// The owner's classifier sample, verbatim, arriving as a
17711    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17712    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17713    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17714    /// and the guidance tail as `help`.
17715    #[test]
17716    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17717        let event = provider_control_event(
17718            AdapterFamily::Acp,
17719            ProviderEvent::ToolCompleted {
17720                id: "toolu_9".to_owned(),
17721                output: CLASSIFIER_SAMPLE.to_owned(),
17722                is_error: true,
17723                duration_ms: None,
17724                agent_id: None,
17725                non_execution_kind: Some("permission-rule".to_owned()),
17726            },
17727        );
17728        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17729        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17730        assert_eq!(reason, "Blocked by classifier");
17731        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17732        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17733        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17734    }
17735
17736    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17737    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17738    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17739    /// vendor code as `reason_kind`.
17740    #[test]
17741    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17742        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.";
17743        let event = provider_control_event(
17744            AdapterFamily::Acp,
17745            ProviderEvent::SessionEnded {
17746                result: "JSON-RPC error -32603: Internal error".to_owned(),
17747                cost_usd: None,
17748                is_error: true,
17749                stop_reason: Some(ProviderStopReason::ProviderError {
17750                    code: -32603,
17751                    message: message.to_owned(),
17752                    vendor_code: Some("usageLimitExceeded".to_owned()),
17753                }),
17754            },
17755        );
17756        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17757        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17758        assert_eq!(reason, message);
17759        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17760        assert_eq!(tool_class, "Session");
17761    }
17762
17763    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17764    /// `authority: ProviderRefusal`.
17765    #[test]
17766    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17767        let event = provider_control_event(
17768            AdapterFamily::Acp,
17769            ProviderEvent::SessionEnded {
17770                result: "refusal".to_owned(),
17771                cost_usd: None,
17772                is_error: true,
17773                stop_reason: Some(ProviderStopReason::Refusal),
17774            },
17775        );
17776        let (_, _, authority, _, _, _) = blocked(&event);
17777        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17778    }
17779
17780    /// A failed tool completion with no typed `non_execution_kind` -- or one
17781    /// that only says the turn stopped -- is a plain execution failure, never a
17782    /// block; nor is an agent's own narrative claiming one.
17783    #[test]
17784    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17785        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17786            let event = provider_control_event(
17787                AdapterFamily::Acp,
17788                ProviderEvent::ToolCompleted {
17789                    id: "toolu_1".to_owned(),
17790                    output: "command exited with status 1".to_owned(),
17791                    is_error: true,
17792                    duration_ms: Some(12),
17793                    agent_id: None,
17794                    non_execution_kind,
17795                },
17796            );
17797            assert!(agent_stream_chunk(&event).is_none());
17798        }
17799        let narrative = provider_control_event(
17800            AdapterFamily::Acp,
17801            ProviderEvent::ToolCompleted {
17802                id: "toolu_2".to_owned(),
17803                output: "the harness blocked the write, approval needed".to_owned(),
17804                is_error: false,
17805                duration_ms: Some(3),
17806                agent_id: None,
17807                non_execution_kind: None,
17808            },
17809        );
17810        assert!(agent_stream_chunk(&narrative).is_none());
17811    }
17812
17813    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17814    /// execution outcome: a granted request that failed while running was
17815    /// still authorized.
17816    #[test]
17817    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17818        for (decision, outcome) in [
17819            (
17820                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17821                ProviderHostRequestOutcome::Executed,
17822            ),
17823            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17824            (
17825                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17826                ProviderHostRequestOutcome::Failed {
17827                    error: "terminal/create spawn failed: os error 3".to_owned(),
17828                },
17829            ),
17830        ] {
17831            let event = provider_control_event_at(
17832                AdapterFamily::Acp,
17833                "claude-code",
17834                3,
17835                ProviderEvent::HostRequestObserved {
17836                    method: "fs/read_text_file".to_owned(),
17837                    params_json: String::new(),
17838                    decision: decision.clone(),
17839                    outcome,
17840                    reason: None,
17841                },
17842            );
17843            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17844        }
17845    }
17846
17847    /// A deferred `session/request_permission` resolved by an operator or an
17848    /// expired deadline carries no reason text -- the chunk falls back to a
17849    /// categorical sentence naming the authority rather than inventing detail.
17850    #[test]
17851    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17852        for (by, expected_authority, expected_reason) in [
17853            (
17854                ProviderHostDecisionAuthority::Operator,
17855                BlockAuthorityV1::Operator,
17856                "declined by the operator",
17857            ),
17858            (
17859                ProviderHostDecisionAuthority::DeadlinePolicy,
17860                BlockAuthorityV1::HostDeadline,
17861                "denied by host policy after the operator deadline expired",
17862            ),
17863        ] {
17864            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17865            assert_eq!(authority, expected_authority);
17866            assert_eq!(reason, expected_reason);
17867        }
17868    }
17869
17870    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17871    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17872    /// outright rather than truncating.
17873    #[test]
17874    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17875        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17876        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17877            ProviderHostDecisionAuthority::Policy,
17878            Some(oversized),
17879        ));
17880        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17881    }
17882}
17883
17884#[cfg(test)]
17885mod standalone_capability_platform_tests {
17886    use super::*;
17887
17888    #[test]
17889    fn standalone_workspace_capability_requires_windows_identity_guards() {
17890        let capabilities = baseline_capabilities().unwrap();
17891        let advertised = capabilities.iter().any(|capability| {
17892            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17893        });
17894        assert_eq!(advertised, cfg!(windows));
17895
17896        let request = NodeRequest::CreateStandaloneWorkspace {
17897            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17898            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17899            initial_branch: None,
17900        };
17901        assert_eq!(
17902            request_uses_unnegotiated_capability(&request, &capabilities),
17903            !cfg!(windows),
17904        );
17905    }
17906}
17907
17908
17909#[cfg(test)]
17910mod station_knobs_resolve_tests {
17911    use super::*;
17912    use gate4agent_catalog::EnvMutation;
17913    use gate4agent_runtime_native::{
17914        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17915        NativeLaunchProfileId,
17916    };
17917    use std::ffi::OsString;
17918    use std::sync::Arc;
17919
17920    struct EmptyEnvironmentResolver;
17921
17922    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17923        fn resolve_child_environment(
17924            &self,
17925        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17926            Ok(vec![EnvMutation {
17927                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17928                value: None,
17929            }])
17930        }
17931    }
17932
17933    fn agent(value: &str) -> AgentId {
17934        AgentId::new(value).unwrap()
17935    }
17936
17937    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17938        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17939        let profile_revision =
17940            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17941        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17942            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17943            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17944            provider: agent("claude"),
17945            mode: SessionMode::Pty,
17946            terminal_size: gate4agent_types::TerminalSize {
17947                rows: 24,
17948                columns: 80,
17949            },
17950            prompt: None,
17951            bundle_id: None,
17952            context_id: None,
17953            environment_profile_id: Some(profile_id.clone()),
17954        };
17955        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17956        let catalog = active_registry().unwrap();
17957        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17958        let control = runtime.native_launch_profile_control();
17959        let node_profile = NodeEnvironmentProfile::new(
17960            profile_id.clone(),
17961            profile_revision,
17962            agent("claude"),
17963            [NativeLaunchProfile::new(
17964                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17965                agent("claude"),
17966                TransportKind::Pty,
17967                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17968                Arc::new(EmptyEnvironmentResolver),
17969            )
17970            .unwrap()],
17971        )
17972        .unwrap();
17973        let (binding, native_profiles, _materialization) = node_profile.into_parts();
17974        for native_profile in native_profiles {
17975            runtime.upsert_native_launch_profile(native_profile).unwrap();
17976        }
17977        let workspace = WorkspaceConfig::new(
17978            WorkspaceId::new("primary").unwrap(),
17979            std::env::current_dir().unwrap(),
17980        )
17981        .unwrap();
17982        let shared = NodeShared::new_with_incarnation(
17983            handle,
17984            "fixture-token".to_owned(),
17985            NodeId::new("node-station-knobs-test").unwrap(),
17986            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17987            vec![workspace],
17988            vec![agent("claude")],
17989            ProviderRuntimeStatuses::default(),
17990            None,
17991            Vec::new(),
17992            Vec::new(),
17993            spawn_profiles,
17994            Some(control),
17995            None,
17996            None,
17997            None,
17998            Vec::new(),
17999            Vec::new(),
18000            Vec::new(),
18001            Vec::new(),
18002            None,
18003            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18004        );
18005        shared
18006            .environment_profiles
18007            .write()
18008            .unwrap_or_else(|poisoned| poisoned.into_inner())
18009            .insert(binding.id.clone(), binding);
18010        (shared, spawn_defaults)
18011    }
18012
18013    /// Empty-default catalog refuses unknown network_allowlist ids.
18014    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18015    #[test]
18016    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18017        let (shared, defaults) = station_knobs_fixture();
18018        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18019        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18020        let spec = crate::protocol::SpawnSpec {
18021            target: crate::protocol::SpawnTarget {
18022                node_id: shared.node_id.clone(),
18023                workspace_id: WorkspaceId::new("primary").unwrap(),
18024                worktree_id: None,
18025            },
18026            profile_id: defaults.profile_id.clone(),
18027            expected_profile_revision: defaults.revision.clone(),
18028            overrides: crate::protocol::SpawnOverrides {
18029                network_allowlist: Some(unknown),
18030                browser_profile_id: Some(browser),
18031                ..crate::protocol::SpawnOverrides::default()
18032            },
18033            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18034            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18035                "station-knobs-unknown-allowlist",
18036            )
18037            .unwrap(),
18038            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18039        };
18040        let resolved = spec.resolve(&defaults).unwrap();
18041        let failure = shared
18042            .resolve_environment_profile(&resolved)
18043            .expect_err("unknown allowlist must refuse");
18044        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18045    }
18046
18047    /// Registered network_allowlist (+ browser_profile_id when feature off)
18048    /// echo onto the env-profile receipt. Empty catalog still allows omit
18049    /// (None). Empty/whitespace refuse is type-level. Feature-off
18050    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18051    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18052    #[test]
18053    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18054        let (shared, defaults) = station_knobs_fixture();
18055        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18056        #[cfg(not(feature = "dig2-station-probe"))]
18057        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18058        shared
18059            .network_allowlist_catalog
18060            .write()
18061            .unwrap_or_else(|poisoned| poisoned.into_inner())
18062            .insert_id(allowlist.clone())
18063            .unwrap();
18064        let spec = crate::protocol::SpawnSpec {
18065            target: crate::protocol::SpawnTarget {
18066                node_id: shared.node_id.clone(),
18067                workspace_id: WorkspaceId::new("primary").unwrap(),
18068                worktree_id: None,
18069            },
18070            profile_id: defaults.profile_id.clone(),
18071            expected_profile_revision: defaults.revision.clone(),
18072            overrides: crate::protocol::SpawnOverrides {
18073                network_allowlist: Some(allowlist.clone()),
18074                #[cfg(not(feature = "dig2-station-probe"))]
18075                browser_profile_id: Some(browser.clone()),
18076                ..crate::protocol::SpawnOverrides::default()
18077            },
18078            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18079            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18080                .unwrap(),
18081            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18082        };
18083        let resolved = spec.resolve(&defaults).unwrap();
18084        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18085        #[cfg(not(feature = "dig2-station-probe"))]
18086        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18087        let receipt = shared
18088            .resolve_environment_profile(&resolved)
18089            .expect("resolve ok")
18090            .expect("env profile present");
18091        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18092        #[cfg(not(feature = "dig2-station-probe"))]
18093        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18094        #[cfg(feature = "dig2-station-probe")]
18095        assert_eq!(receipt.browser_profile_id, None);
18096        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18097
18098        let omitted = crate::protocol::SpawnSpec {
18099            overrides: crate::protocol::SpawnOverrides::default(),
18100            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18101                .unwrap(),
18102            ..spec
18103        };
18104        let resolved_omit = omitted.resolve(&defaults).unwrap();
18105        let bare = shared
18106            .resolve_environment_profile(&resolved_omit)
18107            .expect("resolve ok")
18108            .expect("env profile present");
18109        assert_eq!(bare.network_allowlist, None);
18110        assert_eq!(bare.browser_profile_id, None);
18111    }
18112
18113    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18114    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18115    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18116    /// unit-tested separately (probe refuses before lease on Linux).
18117    #[cfg(feature = "dig2-station-probe")]
18118    #[test]
18119    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18120        let (shared, defaults) = station_knobs_fixture();
18121        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18122        let spec = crate::protocol::SpawnSpec {
18123            target: crate::protocol::SpawnTarget {
18124                node_id: shared.node_id.clone(),
18125                workspace_id: WorkspaceId::new("primary").unwrap(),
18126                worktree_id: None,
18127            },
18128            profile_id: defaults.profile_id.clone(),
18129            expected_profile_revision: defaults.revision.clone(),
18130            overrides: crate::protocol::SpawnOverrides {
18131                browser_profile_id: Some(browser),
18132                ..crate::protocol::SpawnOverrides::default()
18133            },
18134            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18135            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18136                "station-knobs-probe-refuse",
18137            )
18138            .unwrap(),
18139            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18140        };
18141        let resolved = spec.resolve(&defaults).unwrap();
18142        let failure = shared
18143            .resolve_environment_profile(&resolved)
18144            .expect_err("browser_profile with failing probe must refuse");
18145        #[cfg(windows)]
18146        {
18147            assert!(
18148                matches!(
18149                    failure.code,
18150                    NodeFailureCode::BrowserStationUnreachable
18151                        | NodeFailureCode::BrowserStationProbeUnavailable
18152                ),
18153                "unexpected code {:?}",
18154                failure.code
18155            );
18156        }
18157        #[cfg(not(windows))]
18158        {
18159            assert_eq!(
18160                failure.code,
18161                NodeFailureCode::BrowserStationProbeUnavailable
18162            );
18163        }
18164    }
18165
18166    /// Feature on: omitting browser_profile_id still resolves (no probe).
18167    #[cfg(feature = "dig2-station-probe")]
18168    #[test]
18169    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18170        let (shared, defaults) = station_knobs_fixture();
18171        let spec = crate::protocol::SpawnSpec {
18172            target: crate::protocol::SpawnTarget {
18173                node_id: shared.node_id.clone(),
18174                workspace_id: WorkspaceId::new("primary").unwrap(),
18175                worktree_id: None,
18176            },
18177            profile_id: defaults.profile_id.clone(),
18178            expected_profile_revision: defaults.revision.clone(),
18179            overrides: crate::protocol::SpawnOverrides::default(),
18180            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18181            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18182                "station-knobs-probe-omit",
18183            )
18184            .unwrap(),
18185            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18186        };
18187        let resolved = spec.resolve(&defaults).unwrap();
18188        let receipt = shared
18189            .resolve_environment_profile(&resolved)
18190            .expect("omit browser_profile must not probe")
18191            .expect("env profile present");
18192        assert_eq!(receipt.browser_profile_id, None);
18193    }
18194
18195    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18196    /// refuses a second holder for the same opaque profile id; release on
18197    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18198    #[cfg(feature = "dig2-station-probe")]
18199    #[test]
18200    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18201        let (shared, _defaults) = station_knobs_fixture();
18202        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18203        let workspace_id = WorkspaceId::new("primary").unwrap();
18204        let address_a = SessionAddress {
18205            workspace_id: workspace_id.clone(),
18206            session: SessionKey {
18207                instance_id: AgentInstanceId(101),
18208                generation: SessionGeneration(1),
18209            },
18210        };
18211        let address_b = SessionAddress {
18212            workspace_id: workspace_id.clone(),
18213            session: SessionKey {
18214                instance_id: AgentInstanceId(102),
18215                generation: SessionGeneration(1),
18216            },
18217        };
18218        let guard_a = crate::dig2_station_lease::acquire_lease(
18219            &shared.browser_station_leases,
18220            profile.clone(),
18221            address_a.session.instance_id,
18222            1,
18223        )
18224        .expect("first lease");
18225        let busy = crate::dig2_station_lease::acquire_lease(
18226            &shared.browser_station_leases,
18227            profile.clone(),
18228            address_b.session.instance_id,
18229            2,
18230        )
18231        .expect_err("second holder must refuse");
18232        assert!(matches!(
18233            busy,
18234            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18235        ));
18236        // Simulate a bound session so remove_binding clears the lease.
18237        shared.bind_session_with_policy(
18238            &address_a,
18239            ProviderRuntimePolicy::raw_pty(),
18240            None,
18241        );
18242        guard_a.retain();
18243        assert!(shared.remove_binding(&address_a).is_some());
18244        crate::dig2_station_lease::acquire_lease(
18245            &shared.browser_station_leases,
18246            profile,
18247            address_b.session.instance_id,
18248            3,
18249        )
18250        .expect("reacquire after clear")
18251        .retain();
18252        assert!(
18253            crate::dig2_station_lease::release_lease_for_holder(
18254                &shared.browser_station_leases,
18255                address_b.session.instance_id,
18256            )
18257        );
18258    }
18259
18260    /// Feature-off: no browser_station_leases field; stub echo documented in
18261    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18262    #[cfg(not(feature = "dig2-station-probe"))]
18263    #[test]
18264    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18265        let (shared, defaults) = station_knobs_fixture();
18266        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18267        let spec = crate::protocol::SpawnSpec {
18268            target: crate::protocol::SpawnTarget {
18269                node_id: shared.node_id.clone(),
18270                workspace_id: WorkspaceId::new("primary").unwrap(),
18271                worktree_id: None,
18272            },
18273            profile_id: defaults.profile_id.clone(),
18274            expected_profile_revision: defaults.revision.clone(),
18275            overrides: crate::protocol::SpawnOverrides {
18276                browser_profile_id: Some(browser.clone()),
18277                ..crate::protocol::SpawnOverrides::default()
18278            },
18279            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18280            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18281                .unwrap(),
18282            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18283        };
18284        let resolved = spec.resolve(&defaults).unwrap();
18285        let receipt = shared
18286            .resolve_environment_profile(&resolved)
18287            .expect("stub resolve")
18288            .expect("env profile");
18289        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18290    }
18291
18292
18293    /// Startup file loader registers ids into the empty-default catalog.
18294    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18295    #[test]
18296    fn network_allowlist_catalog_loads_from_temp_file() {
18297        let unique = std::time::SystemTime::now()
18298            .duration_since(std::time::UNIX_EPOCH)
18299            .unwrap()
18300            .as_nanos();
18301        let path = std::env::temp_dir().join(format!(
18302            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18303            std::process::id(),
18304        ));
18305        std::fs::write(
18306            &path,
18307            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18308        )
18309        .unwrap();
18310        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18311            .expect("temp catalog file must load");
18312        let _ = std::fs::remove_file(&path);
18313        assert_eq!(loaded.len(), 2);
18314        let (shared, defaults) = station_knobs_fixture();
18315        {
18316            let mut catalog = shared
18317                .network_allowlist_catalog
18318                .write()
18319                .unwrap_or_else(|poisoned| poisoned.into_inner());
18320            *catalog = loaded;
18321        }
18322        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18323        let spec = crate::protocol::SpawnSpec {
18324            target: crate::protocol::SpawnTarget {
18325                node_id: shared.node_id.clone(),
18326                workspace_id: WorkspaceId::new("primary").unwrap(),
18327                worktree_id: None,
18328            },
18329            profile_id: defaults.profile_id.clone(),
18330            expected_profile_revision: defaults.revision.clone(),
18331            overrides: crate::protocol::SpawnOverrides {
18332                network_allowlist: Some(allowlist.clone()),
18333                ..crate::protocol::SpawnOverrides::default()
18334            },
18335            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18336            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18337                "station-knobs-catalog-file",
18338            )
18339            .unwrap(),
18340            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18341        };
18342        let resolved = spec.resolve(&defaults).unwrap();
18343        let receipt = shared
18344            .resolve_environment_profile(&resolved)
18345            .expect("registered via file load")
18346            .expect("env profile present");
18347        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18348    }
18349
18350    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18351    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18352    #[test]
18353    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18354        let (shared, _defaults) = station_knobs_fixture();
18355        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18356        shared
18357            .network_allowlist_catalog
18358            .write()
18359            .unwrap_or_else(|poisoned| poisoned.into_inner())
18360            .insert_id(allowlist.clone())
18361            .unwrap();
18362        let snapshot = shared.snapshot();
18363        let inventory = snapshot
18364            .launch_inventory
18365            .expect("launch inventory present");
18366        let ids = inventory
18367            .network_allowlists
18368            .expect("non-empty catalog surfaces ids");
18369        assert_eq!(ids, vec![allowlist]);
18370    }
18371
18372    /// Track B: registered id with provider_native codex_network_access refuses
18373    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18374    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18375    #[test]
18376    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18377        let (shared, defaults) = station_knobs_fixture();
18378        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18379        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18380            id: allowlist.clone(),
18381            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18382                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18383                peer: "127.0.0.1:443".to_owned(),
18384            }],
18385            provider_native: Some(
18386                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18387                    codex_network_access: Some(true),
18388                },
18389            ),
18390        };
18391        shared
18392            .network_allowlist_catalog
18393            .write()
18394            .unwrap_or_else(|poisoned| poisoned.into_inner())
18395            .insert(entry)
18396            .unwrap();
18397        let spec = crate::protocol::SpawnSpec {
18398            target: crate::protocol::SpawnTarget {
18399                node_id: shared.node_id.clone(),
18400                workspace_id: WorkspaceId::new("primary").unwrap(),
18401                worktree_id: None,
18402            },
18403            profile_id: defaults.profile_id.clone(),
18404            expected_profile_revision: defaults.revision.clone(),
18405            overrides: crate::protocol::SpawnOverrides {
18406                network_allowlist: Some(allowlist),
18407                ..crate::protocol::SpawnOverrides::default()
18408            },
18409            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18410            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18411                "station-knobs-unsupported-mapping",
18412            )
18413            .unwrap(),
18414            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18415        };
18416        let resolved = spec.resolve(&defaults).unwrap();
18417        assert_eq!(resolved.provider.as_str(), "claude");
18418        let failure = shared
18419            .resolve_environment_profile(&resolved)
18420            .expect_err("claude + codex_network_access must refuse");
18421        assert_eq!(
18422            failure.code,
18423            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18424        );
18425    }
18426
18427    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18428    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18429    /// Never secrets on C2.
18430    #[test]
18431    fn resolve_codex_network_access_overlay_moderate_only() {
18432        let (shared, defaults) = station_knobs_fixture();
18433        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18434        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18435            id: allowlist.clone(),
18436            permits: Vec::new(),
18437            provider_native: Some(
18438                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18439                    codex_network_access: Some(true),
18440                },
18441            ),
18442        };
18443        shared
18444            .network_allowlist_catalog
18445            .write()
18446            .unwrap_or_else(|poisoned| poisoned.into_inner())
18447            .insert(entry)
18448            .unwrap();
18449
18450        let overlay = shared
18451            .resolve_codex_network_access_overlay_args(
18452                &agent("codex"),
18453                SessionMode::Pty,
18454                ApprovalLevel::Moderate,
18455                Some(&allowlist),
18456            )
18457            .expect("Moderate Codex maps network_access");
18458        assert_eq!(
18459            overlay,
18460            vec![
18461                std::ffi::OsString::from("-c"),
18462                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18463            ]
18464        );
18465
18466        let acp = shared
18467            .resolve_codex_network_access_overlay_args(
18468                &agent("codex"),
18469                SessionMode::Acp,
18470                ApprovalLevel::Moderate,
18471                Some(&allowlist),
18472            )
18473            .unwrap();
18474        assert_eq!(acp, overlay);
18475
18476        let inline = shared
18477            .resolve_codex_network_access_overlay_args(
18478                &agent("codex"),
18479                SessionMode::Inline,
18480                ApprovalLevel::Moderate,
18481                Some(&allowlist),
18482            )
18483            .unwrap_err();
18484        assert_eq!(
18485            inline.code,
18486            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18487        );
18488
18489        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18490            let spec = crate::protocol::SpawnSpec {
18491                target: crate::protocol::SpawnTarget {
18492                    node_id: shared.node_id.clone(),
18493                    workspace_id: WorkspaceId::new("primary").unwrap(),
18494                    worktree_id: None,
18495                },
18496                profile_id: defaults.profile_id.clone(),
18497                expected_profile_revision: defaults.revision.clone(),
18498                overrides: crate::protocol::SpawnOverrides {
18499                    provider: crate::protocol::SpawnOverride::Set {
18500                        value: agent("codex"),
18501                    },
18502                    approval_level: Some(level),
18503                    network_allowlist: Some(allowlist.clone()),
18504                    ..crate::protocol::SpawnOverrides::default()
18505                },
18506                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18507                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18508                    "codex-net-refuse-{}",
18509                    match level {
18510                        ApprovalLevel::ReadOnly => "ro",
18511                        ApprovalLevel::FullAuto => "fa",
18512                        _ => "other",
18513                    }
18514                ))
18515                .unwrap(),
18516                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18517            };
18518            let resolved = spec.resolve(&defaults).unwrap();
18519            let failure = shared
18520                .resolve_environment_profile(&resolved)
18521                .expect_err("RO/FullAuto must refuse");
18522            assert_eq!(
18523                failure.code,
18524                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18525            );
18526        }
18527    }
18528
18529    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18530    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18531    /// Never secrets on C2; does not invent provider_native Claude keys.
18532    #[test]
18533    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18534        let (shared, _defaults) = station_knobs_fixture();
18535        let allowlist =
18536            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18537        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18538            id: allowlist.clone(),
18539            permits: vec![
18540                crate::network_allowlist_catalog::NetworkPermitSketch {
18541                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18542                    peer: "github.com:443".to_owned(),
18543                },
18544                crate::network_allowlist_catalog::NetworkPermitSketch {
18545                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18546                    peer: "*.npmjs.org".to_owned(),
18547                },
18548            ],
18549            provider_native: None,
18550        };
18551        shared
18552            .network_allowlist_catalog
18553            .write()
18554            .unwrap_or_else(|poisoned| poisoned.into_inner())
18555            .insert(entry)
18556            .unwrap();
18557
18558        let unique = std::time::SystemTime::now()
18559            .duration_since(std::time::UNIX_EPOCH)
18560            .unwrap()
18561            .as_nanos();
18562        let provider_home = std::env::temp_dir().join(format!(
18563            "gate4agent-claude-provider-home-{}-{unique}",
18564            std::process::id(),
18565        ));
18566        std::fs::create_dir_all(&provider_home).unwrap();
18567
18568        let overlay = shared
18569            .resolve_network_allowlist_overlay_args(
18570                &agent("claude"),
18571                SessionMode::Pty,
18572                ApprovalLevel::Moderate,
18573                Some(&allowlist),
18574                Some(provider_home.as_path()),
18575            )
18576            .expect("Claude PTY settings overlay on unix");
18577        let settings_path = provider_home.join(
18578            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18579        );
18580        assert_eq!(
18581            overlay,
18582            vec![
18583                std::ffi::OsString::from("--settings"),
18584                std::ffi::OsString::from(settings_path.as_os_str()),
18585            ]
18586        );
18587        let body = std::fs::read_to_string(&settings_path).unwrap();
18588        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18589        assert_eq!(value["sandbox"]["enabled"], true);
18590        assert_eq!(
18591            value["sandbox"]["network"]["allowedDomains"],
18592            serde_json::json!(["github.com", "*.npmjs.org"])
18593        );
18594        assert!(settings_path.starts_with(&provider_home));
18595
18596        let acp = shared
18597            .resolve_network_allowlist_overlay_args(
18598                &agent("claude"),
18599                SessionMode::Acp,
18600                ApprovalLevel::Moderate,
18601                Some(&allowlist),
18602                Some(provider_home.as_path()),
18603            )
18604            .expect_err("Claude ACP must refuse settings overlay");
18605        assert_eq!(
18606            acp.code,
18607            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18608        );
18609
18610        let no_home = shared
18611            .resolve_network_allowlist_overlay_args(
18612                &agent("claude"),
18613                SessionMode::Pty,
18614                ApprovalLevel::Moderate,
18615                Some(&allowlist),
18616                None,
18617            )
18618            .expect_err("Claude overlay requires ProviderHome");
18619        assert_eq!(
18620            no_home.code,
18621            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18622        );
18623
18624        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18625        shared
18626            .network_allowlist_catalog
18627            .write()
18628            .unwrap_or_else(|poisoned| poisoned.into_inner())
18629            .insert_id(bare.clone())
18630            .unwrap();
18631        let empty = shared
18632            .resolve_network_allowlist_overlay_args(
18633                &agent("claude"),
18634                SessionMode::Pty,
18635                ApprovalLevel::Moderate,
18636                Some(&bare),
18637                Some(provider_home.as_path()),
18638            )
18639            .unwrap();
18640        assert!(empty.is_empty());
18641
18642        let _ = std::fs::remove_dir_all(&provider_home);
18643    }
18644
18645    /// Kimi / Grok with permits stay without invented argv overlays.
18646    #[test]
18647    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18648        let (shared, _defaults) = station_knobs_fixture();
18649        let allowlist =
18650            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18651        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18652            id: allowlist.clone(),
18653            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18654                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18655                peer: "example.com:443".to_owned(),
18656            }],
18657            provider_native: None,
18658        };
18659        shared
18660            .network_allowlist_catalog
18661            .write()
18662            .unwrap_or_else(|poisoned| poisoned.into_inner())
18663            .insert(entry)
18664            .unwrap();
18665        for provider in ["kimi", "grok"] {
18666            let overlay = shared
18667                .resolve_network_allowlist_overlay_args(
18668                    &agent(provider),
18669                    SessionMode::Pty,
18670                    ApprovalLevel::Moderate,
18671                    Some(&allowlist),
18672                    None,
18673                )
18674                .unwrap();
18675            assert!(
18676                overlay.is_empty(),
18677                "{provider} must not invent network argv from permits"
18678            );
18679        }
18680    }
18681
18682    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18683    #[test]
18684    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18685        let unique = std::time::SystemTime::now()
18686            .duration_since(std::time::UNIX_EPOCH)
18687            .unwrap()
18688            .as_nanos();
18689        let path = std::env::temp_dir().join(format!(
18690            "gate4agent-node-allowlist-json-{}-{unique}.json",
18691            std::process::id(),
18692        ));
18693        std::fs::write(
18694            &path,
18695            r#"{
18696              "schema_version": 2,
18697              "entries": [
18698                {
18699                  "id": "egress-default",
18700                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18701                },
18702                { "id": "lab-egress" }
18703              ]
18704            }"#,
18705        )
18706        .unwrap();
18707        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18708            .expect("json v2 catalog must load");
18709        let _ = std::fs::remove_file(&path);
18710        assert_eq!(loaded.len(), 2);
18711        let entry = loaded
18712            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18713            .unwrap();
18714        assert_eq!(entry.permits.len(), 1);
18715        let (shared, defaults) = station_knobs_fixture();
18716        {
18717            let mut catalog = shared
18718                .network_allowlist_catalog
18719                .write()
18720                .unwrap_or_else(|poisoned| poisoned.into_inner());
18721            *catalog = loaded;
18722        }
18723        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18724        let spec = crate::protocol::SpawnSpec {
18725            target: crate::protocol::SpawnTarget {
18726                node_id: shared.node_id.clone(),
18727                workspace_id: WorkspaceId::new("primary").unwrap(),
18728                worktree_id: None,
18729            },
18730            profile_id: defaults.profile_id.clone(),
18731            expected_profile_revision: defaults.revision.clone(),
18732            overrides: crate::protocol::SpawnOverrides {
18733                network_allowlist: Some(allowlist.clone()),
18734                ..crate::protocol::SpawnOverrides::default()
18735            },
18736            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18737            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18738                "station-knobs-catalog-json",
18739            )
18740            .unwrap(),
18741            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18742        };
18743        let resolved = spec.resolve(&defaults).unwrap();
18744        let receipt = shared
18745            .resolve_environment_profile(&resolved)
18746            .expect("permits-only entry ok for claude")
18747            .expect("env profile present");
18748        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18749        let inventory = shared
18750            .snapshot()
18751            .launch_inventory
18752            .expect("launch inventory present");
18753        let ids = inventory.network_allowlists.expect("ids surface");
18754        assert_eq!(
18755            ids,
18756            vec![
18757                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18758                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18759            ]
18760        );
18761    }
18762
18763
18764}
18765
18766
18767#[cfg(test)]
18768mod workspace_environment_collision_tests {
18769    use super::*;
18770    use crate::session_environment::{
18771        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18772    };
18773    use gate4agent_catalog::EnvMutation;
18774    use gate4agent_runtime_native::{
18775        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18776        NativeLaunchProfileId,
18777    };
18778    use std::ffi::OsString;
18779    use std::sync::Arc;
18780
18781    struct EmptyEnvironmentResolver;
18782
18783    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18784        fn resolve_child_environment(
18785            &self,
18786        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18787            Ok(vec![EnvMutation {
18788                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18789                value: None,
18790            }])
18791        }
18792    }
18793
18794    fn agent(value: &str) -> AgentId {
18795        AgentId::new(value).unwrap()
18796    }
18797
18798    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18799    fn claude_config_dir_collision_fixture() -> (
18800        NodeShared,
18801        ResolvedEnvironmentProfileReceipt,
18802        ResolvedEnvironmentProfileReceipt,
18803    ) {
18804        let catalog = active_registry().unwrap();
18805        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18806        let control = runtime.native_launch_profile_control();
18807        let workspace = WorkspaceConfig::new(
18808            WorkspaceId::new("primary").unwrap(),
18809            std::env::current_dir().unwrap(),
18810        )
18811        .unwrap();
18812        let shared = NodeShared::new_with_incarnation(
18813            handle,
18814            "fixture-token".to_owned(),
18815            NodeId::new("node-env-collision-test").unwrap(),
18816            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18817            vec![workspace],
18818            vec![agent("claude")],
18819            ProviderRuntimeStatuses::default(),
18820            None,
18821            Vec::new(),
18822            Vec::new(),
18823            SpawnProfileRegistry::default(),
18824            Some(control),
18825            None,
18826            None,
18827            None,
18828            Vec::new(),
18829            Vec::new(),
18830            Vec::new(),
18831            Vec::new(),
18832            None,
18833            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18834        );
18835
18836        let mut receipts = Vec::new();
18837        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18838            let profile_id =
18839                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18840            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18841                "claude-config-dir-{suffix}-r1"
18842            ))
18843            .unwrap();
18844            let materialization = NodeSessionMaterializationProfile::new(
18845                Vec::new(),
18846                vec![NodeSessionPathBinding::new(
18847                    "CLAUDE_CONFIG_DIR",
18848                    NodeSessionPathClass::ProviderHome,
18849                )
18850                .unwrap()],
18851                Vec::new(),
18852            )
18853            .unwrap();
18854            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18855                profile_id.clone(),
18856                profile_revision.clone(),
18857                agent("claude"),
18858                [NativeLaunchProfile::new(
18859                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18860                        .unwrap(),
18861                    agent("claude"),
18862                    TransportKind::Pty,
18863                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18864                    Arc::new(EmptyEnvironmentResolver),
18865                )
18866                .unwrap()],
18867                Some(materialization),
18868            )
18869            .unwrap();
18870            let (binding, native_profiles, materialization) = node_profile.into_parts();
18871            for native_profile in native_profiles {
18872                runtime.upsert_native_launch_profile(native_profile).unwrap();
18873            }
18874            shared
18875                .environment_profiles
18876                .write()
18877                .unwrap_or_else(|poisoned| poisoned.into_inner())
18878                .insert(binding.id.clone(), binding);
18879            shared
18880                .environment_materialization_profiles
18881                .write()
18882                .unwrap_or_else(|poisoned| poisoned.into_inner())
18883                .insert(profile_id.clone(), materialization.unwrap());
18884            receipts.push(ResolvedEnvironmentProfileReceipt {
18885                profile_id,
18886                profile_revision,
18887                network_allowlist: None,
18888                browser_profile_id: None,
18889            });
18890        }
18891        let profile_b = receipts.pop().unwrap();
18892        let profile_a = receipts.pop().unwrap();
18893        (shared, profile_a, profile_b)
18894    }
18895
18896    #[test]
18897    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18898        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18899        let primary = WorkspaceId::new("primary").unwrap();
18900        let worktree = WorkspaceId::new("worktree-b").unwrap();
18901        let address = SessionAddress {
18902            workspace_id: primary.clone(),
18903            session: SessionKey {
18904                instance_id: AgentInstanceId(41),
18905                generation: SessionGeneration(1),
18906            },
18907        };
18908        shared.bind_session_with_policy(
18909            &address,
18910            ProviderRuntimePolicy::raw_pty(),
18911            Some(profile_a.clone()),
18912        );
18913
18914        let refused = shared
18915            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18916            .unwrap_err();
18917        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18918
18919        // Some vs None on the same cwd is also a collision.
18920        let refused_none = shared
18921            .ensure_workspace_environment_profile_compatible(&primary, None)
18922            .unwrap_err();
18923        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18924
18925        // Same profile on the same workspace remains allowed.
18926        shared
18927            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18928            .unwrap();
18929
18930        // Distinct workspace id (managed-worktree parallel path) allows profile B.
18931        shared
18932            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18933            .unwrap();
18934    }
18935
18936    #[tokio::test]
18937    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18938        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18939        let primary = WorkspaceId::new("primary").unwrap();
18940        let address = SessionAddress {
18941            workspace_id: primary.clone(),
18942            session: SessionKey {
18943                instance_id: AgentInstanceId(41),
18944                generation: SessionGeneration(1),
18945            },
18946        };
18947        shared.bind_session_with_policy(
18948            &address,
18949            ProviderRuntimePolicy::raw_pty(),
18950            Some(profile_a),
18951        );
18952
18953        let failure = shared
18954            .spawn_session_with_deadline(
18955                primary,
18956                agent("claude"),
18957                SessionMode::Pty,
18958                gate4agent_types::TerminalSize {
18959                    rows: 24,
18960                    columns: 80,
18961                },
18962                None,
18963                Some(profile_b),
18964                None,
18965                None,
18966                None,
18967                None,
18968                SpawnRecordPolicy::ProviderIdentityOnly,
18969                Some(Instant::now() + Duration::from_secs(30)),
18970                &[],
18971                None,
18972                ApprovalLevel::default(),
18973                None,
18974                None,
18975            )
18976            .await
18977            .unwrap_err();
18978        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18979        // Guard runs before instance id mint / bind.
18980        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18981        assert!(shared.handle.snapshot().sessions.is_empty());
18982    }
18983}
18984
18985#[cfg(test)]
18986mod spawn_characterization_tests {
18987    use super::*;
18988    use crate::session_environment::{
18989        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18990        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18991        NodeSessionPathClass,
18992    };
18993    use gate4agent_catalog::EnvMutation;
18994    use gate4agent_runtime_native::{
18995        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18996        NativeLaunchProfile, NativeLaunchProfileId,
18997    };
18998    use std::ffi::OsString;
18999    use std::sync::Arc;
19000
19001    struct EmptyEnvironmentResolver;
19002
19003    struct FixtureSecretResolver {
19004        deny: Arc<AtomicBool>,
19005    }
19006
19007    impl NodeSecretResolver for FixtureSecretResolver {
19008        fn resolve(
19009            &self,
19010            _reference: &NodeSecretReference,
19011        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19012            if self.deny.load(Ordering::Acquire) {
19013                Err(NodeSecretResolveError::Denied)
19014            } else {
19015                NodeSecretValue::text("fixture-secret")
19016                    .map_err(|_| NodeSecretResolveError::Unavailable)
19017            }
19018        }
19019    }
19020
19021    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19022        fn resolve_child_environment(
19023            &self,
19024        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19025            Ok(vec![EnvMutation {
19026                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19027                value: None,
19028            }])
19029        }
19030    }
19031
19032    fn agent(value: &str) -> AgentId {
19033        AgentId::new(value).unwrap()
19034    }
19035
19036    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19037        let unique = std::time::SystemTime::now()
19038            .duration_since(std::time::UNIX_EPOCH)
19039            .unwrap()
19040            .as_nanos();
19041        std::env::temp_dir().join(format!(
19042            "gate4agent-node-{label}-{}-{unique}",
19043            std::process::id(),
19044        ))
19045    }
19046
19047    fn materialization_test_shared(
19048        deny_secret: bool,
19049    ) -> (
19050        NodeShared,
19051        NativeRuntime,
19052        ResolvedEnvironmentProfileReceipt,
19053        PathBuf,
19054        Arc<AtomicBool>,
19055    ) {
19056        let workspace = WorkspaceConfig::new(
19057            WorkspaceId::new("primary").unwrap(),
19058            std::env::current_dir().unwrap(),
19059        )
19060        .unwrap();
19061        materialization_test_shared_with_workspace(deny_secret, workspace)
19062    }
19063
19064    fn materialization_test_shared_with_workspace(
19065        deny_secret: bool,
19066        workspace: WorkspaceConfig,
19067    ) -> (
19068        NodeShared,
19069        NativeRuntime,
19070        ResolvedEnvironmentProfileReceipt,
19071        PathBuf,
19072        Arc<AtomicBool>,
19073    ) {
19074        let root = temporary_workspace_root(if deny_secret {
19075            "materialization-denied"
19076        } else {
19077            "materialization-ready"
19078        });
19079        std::fs::create_dir_all(&root).unwrap();
19080        let materialization_root = root.join("session-environments");
19081        let deny = Arc::new(AtomicBool::new(deny_secret));
19082        let resolver = Arc::new(FixtureSecretResolver {
19083            deny: Arc::clone(&deny),
19084        });
19085        let materializer = SessionEnvironmentMaterializer::new(
19086            materialization_root,
19087            resolver,
19088        )
19089        .unwrap();
19090        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19091        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19092            "materialized-claude-r1",
19093        )
19094        .unwrap();
19095        let materialization = NodeSessionMaterializationProfile::new(
19096            vec![NodeSessionEnvironmentMutation::SetSecret {
19097                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19098                reference: NodeSecretReference::new("fixture-token").unwrap(),
19099            }],
19100            vec![NodeSessionPathBinding::new(
19101                "GATE4AGENT_PROVIDER_HOME",
19102                NodeSessionPathClass::ProviderHome,
19103            )
19104            .unwrap()],
19105            vec![NodeSessionFile::secret(
19106                NodeSessionPathClass::Config,
19107                "auth/token",
19108                NodeSecretReference::new("fixture-token").unwrap(),
19109            )
19110            .unwrap()],
19111        )
19112        .unwrap();
19113        let catalog = active_registry().unwrap();
19114        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19115        let control = runtime.native_launch_profile_control();
19116        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19117            profile_id.clone(),
19118            profile_revision.clone(),
19119            agent("claude"),
19120            [NativeLaunchProfile::new(
19121                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19122                agent("claude"),
19123                TransportKind::Pty,
19124                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19125                Arc::new(EmptyEnvironmentResolver),
19126            )
19127            .unwrap()],
19128            Some(materialization),
19129        )
19130        .unwrap();
19131        let (binding, native_profiles, materialization) = node_profile.into_parts();
19132        for native_profile in native_profiles {
19133            runtime.upsert_native_launch_profile(native_profile).unwrap();
19134        }
19135        let shared = NodeShared::new_with_incarnation(
19136            handle,
19137            "fixture-token".to_owned(),
19138            NodeId::new("node-materialization-test").unwrap(),
19139            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19140            vec![workspace],
19141            vec![agent("claude")],
19142            ProviderRuntimeStatuses::default(),
19143            None,
19144            Vec::new(),
19145            Vec::new(),
19146            SpawnProfileRegistry::default(),
19147            Some(control),
19148            None,
19149            Some(materializer),
19150            None,
19151            Vec::new(),
19152            Vec::new(),
19153            Vec::new(),
19154            Vec::new(),
19155            None,
19156            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19157        );
19158        shared
19159            .environment_profiles
19160            .write()
19161            .unwrap_or_else(|poisoned| poisoned.into_inner())
19162            .insert(binding.id.clone(), binding);
19163        shared
19164            .environment_materialization_profiles
19165            .write()
19166            .unwrap_or_else(|poisoned| poisoned.into_inner())
19167            .insert(profile_id.clone(), materialization.unwrap());
19168        (
19169            shared,
19170            runtime,
19171            ResolvedEnvironmentProfileReceipt {
19172                profile_id,
19173                profile_revision,
19174                network_allowlist: None,
19175                browser_profile_id: None,
19176            },
19177            root,
19178            deny,
19179        )
19180    }
19181
19182
19183    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19184
19185    async fn characterization_spawn(
19186        shared: &NodeShared,
19187        mode: SessionMode,
19188        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19189        required: &[ProviderRuntimeCapability],
19190        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19191    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19192        shared
19193            .spawn_session_with_deadline(
19194                WorkspaceId::new("primary").unwrap(),
19195                agent("claude"),
19196                mode,
19197                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19198                None,
19199                environment_profile,
19200                None,
19201                None,
19202                None,
19203                Some(ProviderRuntimePolicy::raw_pty()),
19204                SpawnRecordPolicy::ProviderIdentityOnly,
19205                Some(Instant::now() + Duration::from_secs(30)),
19206                required,
19207                harness_mcp,
19208                ApprovalLevel::default(),
19209                None,
19210                None,
19211            )
19212            .await
19213    }
19214
19215    fn assert_no_session_state(shared: &NodeShared) {
19216        assert!(shared.handle.snapshot().sessions.is_empty());
19217        assert!(shared
19218            .session_bindings
19219            .lock()
19220            .unwrap_or_else(|poisoned| poisoned.into_inner())
19221            .is_empty());
19222        assert!(shared
19223            .materializations
19224            .lock()
19225            .unwrap_or_else(|poisoned| poisoned.into_inner())
19226            .is_empty());
19227    }
19228
19229    #[tokio::test]
19230    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19231        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19232        let error = characterization_spawn(
19233            &shared,
19234            SessionMode::Pty,
19235            None,
19236            &[ProviderRuntimeCapability::SemanticReadiness],
19237            None,
19238        )
19239        .await
19240        .unwrap_err();
19241        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19242        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19243        assert_no_session_state(&shared);
19244        drop(shared);
19245        drop(runtime);
19246        std::fs::remove_dir_all(root).unwrap();
19247    }
19248
19249    #[tokio::test]
19250    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19251        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19252        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19253            .await
19254            .unwrap_err();
19255        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19256        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19257        assert_no_session_state(&shared);
19258        drop(shared);
19259        drop(runtime);
19260        std::fs::remove_dir_all(root).unwrap();
19261    }
19262
19263    #[test]
19264    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19265        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19266        let address = SessionAddress {
19267            workspace_id: WorkspaceId::new("primary").unwrap(),
19268            session: SessionKey {
19269                instance_id: AgentInstanceId(77),
19270                generation: SessionGeneration(1),
19271            },
19272        };
19273        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19274        let removed = shared.remove_binding(&address).expect("binding removed");
19275        assert!(removed.environment_profile.is_some());
19276        assert!(shared.remove_binding(&address).is_none());
19277        assert_no_session_state(&shared);
19278        drop(shared);
19279        drop(runtime);
19280        std::fs::remove_dir_all(root).unwrap();
19281    }
19282
19283    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19284        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19285        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19286        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19287            .expect("review current test executable as fixture helper");
19288        let registry = HarnessMcpProxyRegistry::new(helper);
19289        let spec = crate::protocol::SpawnSpec {
19290            target: crate::protocol::SpawnTarget {
19291                node_id: NodeId::new("node-characterization").unwrap(),
19292                workspace_id: WorkspaceId::new("primary").unwrap(),
19293                worktree_id: None,
19294            },
19295            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19296            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19297            overrides: crate::protocol::SpawnOverrides::default(),
19298            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19299            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19300            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19301        };
19302        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19303        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19304        let launch = HarnessMcpLaunchV1 {
19305            server_name: "fixture-harness".to_owned(),
19306            args: vec!["--session-proxy".to_owned()],
19307            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19308            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19309            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19310            trace: None,
19311            scrub_env: Vec::new(),
19312        };
19313        let expires = unix_time_ms().saturating_add(30_000);
19314        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19315        registry
19316            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19317            .unwrap()
19318    }
19319
19320    #[tokio::test]
19321    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19322        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19323        let prepared = characterization_prepared_harness("claude").await;
19324        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19325            .await
19326            .unwrap_err();
19327        // Inline is stopped by runtime admission before the harness check runs.
19328        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19329        let mismatched = characterization_prepared_harness("codex").await;
19330        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19331            .await
19332            .unwrap_err();
19333        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19334        assert_no_session_state(&shared);
19335        drop(shared);
19336        drop(runtime);
19337        std::fs::remove_dir_all(root).unwrap();
19338    }
19339
19340    #[tokio::test]
19341    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19342        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19343        shared.native_launch_profile_control = None;
19344        let prepared = characterization_prepared_harness("claude").await;
19345        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19346            .await
19347            .unwrap_err();
19348        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19349        assert_no_session_state(&shared);
19350        drop(shared);
19351        drop(runtime);
19352        std::fs::remove_dir_all(root).unwrap();
19353    }
19354}
19355
19356#[cfg(all(test, windows))]
19357mod tests {
19358    use super::*;
19359    use gate4agent_types::ProviderActivity;
19360    use crate::session_environment::{
19361        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19362        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19363        NodeSessionPathClass,
19364    };
19365    use gate4agent_catalog::EnvMutation;
19366    use gate4agent_runtime_native::{
19367        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19368        NativeLaunchProfile, NativeLaunchProfileId,
19369    };
19370    use std::ffi::OsString;
19371    use std::sync::Arc;
19372
19373    struct EmptyEnvironmentResolver;
19374
19375    struct FixtureSecretResolver {
19376        deny: Arc<AtomicBool>,
19377    }
19378
19379    impl NodeSecretResolver for FixtureSecretResolver {
19380        fn resolve(
19381            &self,
19382            _reference: &NodeSecretReference,
19383        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19384            if self.deny.load(Ordering::Acquire) {
19385                Err(NodeSecretResolveError::Denied)
19386            } else {
19387                NodeSecretValue::text("fixture-secret")
19388                    .map_err(|_| NodeSecretResolveError::Unavailable)
19389            }
19390        }
19391    }
19392
19393    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19394        fn resolve_child_environment(
19395            &self,
19396        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19397            Ok(vec![EnvMutation {
19398                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19399                value: None,
19400            }])
19401        }
19402    }
19403
19404    fn agent(value: &str) -> AgentId {
19405        AgentId::new(value).unwrap()
19406    }
19407
19408    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19409        let catalog = active_registry().unwrap();
19410        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19411        let workspace = WorkspaceConfig::new(
19412            WorkspaceId::new("primary").unwrap(),
19413            std::env::current_dir().unwrap(),
19414        )
19415        .unwrap()
19416        .with_worktree_service_mode(mode);
19417        NodeShared::new(
19418            handle,
19419            "fixture-token".to_owned(),
19420            NodeId::new("node-terminal-test").unwrap(),
19421            vec![workspace],
19422            vec![agent("claude")],
19423        )
19424    }
19425
19426    fn terminal_test_shared() -> NodeShared {
19427        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19428    }
19429
19430    #[cfg(feature = "fixture")]
19431    #[test]
19432    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19433        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19434        assert_eq!(probe.latest_code(), None);
19435
19436        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19437        assert_eq!(
19438            probe.latest_code(),
19439            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19440        );
19441
19442        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19443        assert_eq!(
19444            probe.latest_code(),
19445            Some(NodeFailureCode::SpawnDeadlineExceeded),
19446        );
19447
19448        probe.record(None);
19449        assert_eq!(probe.latest_code(), None);
19450    }
19451
19452    #[test]
19453    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19454        let source = temporary_workspace_root("launch-inventory-source");
19455        let allocation = temporary_workspace_root("launch-inventory-allocation");
19456        std::fs::create_dir_all(&source).unwrap();
19457        std::fs::create_dir_all(&allocation).unwrap();
19458        let workspace_id = WorkspaceId::new("primary").unwrap();
19459        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19460            .unwrap()
19461            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19462        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19463            workspace = workspace
19464                .with_managed_worktree_profile(
19465                    ManagedWorktreeProfile::new(
19466                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19467                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19468                        &allocation,
19469                        "gate4agent",
19470                        "HEAD",
19471                        ManagedWorktreeRetention::RemoveWhenReleased,
19472                    )
19473                    .unwrap(),
19474                )
19475                .unwrap();
19476        }
19477        let overflow = workspace.clone().with_managed_worktree_profile(
19478            ManagedWorktreeProfile::new(
19479                WorktreeProfileId::new("overflow").unwrap(),
19480                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19481                &allocation,
19482                "gate4agent",
19483                "HEAD",
19484                ManagedWorktreeRetention::Retain,
19485            )
19486            .unwrap(),
19487        );
19488        assert!(matches!(
19489            overflow,
19490            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19491                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19492                ..
19493            }),
19494        ));
19495
19496        let catalog = active_registry().unwrap();
19497        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19498        let shared = NodeShared::new(
19499            handle,
19500            "fixture-token".to_owned(),
19501            NodeId::new("node-launch-inventory").unwrap(),
19502            vec![workspace],
19503            vec![agent("claude")],
19504        );
19505        let snapshot = shared.snapshot();
19506        let launch = snapshot.launch_inventory.as_ref().unwrap();
19507        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19508        assert!(launch.bundles.as_ref().unwrap().is_empty());
19509        let profiles = snapshot.workspaces[0]
19510            .managed_worktree_profiles
19511            .as_ref()
19512            .unwrap();
19513        assert_eq!(
19514            snapshot.workspaces[0].worktree_service_mode,
19515            Some(WorktreeServiceMode::Managed),
19516        );
19517        assert_eq!(
19518            profiles.profiles.len(),
19519            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19520        );
19521        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19522        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19523            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19524        }
19525        let legacy = snapshot_for_wire(
19526            &shared,
19527            true,
19528            true,
19529            false,
19530            false,
19531            true,
19532            false,
19533            true,
19534            false,
19535            true,
19536        );
19537        assert!(legacy.launch_inventory.is_none());
19538        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19539
19540        drop(shared);
19541        std::fs::remove_dir_all(source).unwrap();
19542        std::fs::remove_dir_all(allocation).unwrap();
19543    }
19544
19545    #[test]
19546    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19547        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19548            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19549        }));
19550        let source = gate4agent_types::ProviderSource {
19551            family: AdapterFamily::PtySemantic,
19552            binding: AdapterBinding::new(
19553                gate4agent_types::AdapterId::new("claude").unwrap(),
19554                "fixture/v1",
19555                gate4agent_types::AdapterVerification::SyntheticFixture,
19556            )
19557            .unwrap(),
19558        };
19559        let provider = ProviderSnapshot {
19560            sequence: 17,
19561            session: Some(ProviderSessionIdentity {
19562                key: ProviderSessionKey::SessionId,
19563                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19564                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19565            }),
19566            model: Some("PRIVATE_MODEL".to_owned()),
19567            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19568            completed_turns: 3,
19569            usage: gate4agent_types::TokenUsage {
19570                input_tokens: 10,
19571                output_tokens: 20,
19572                cache_read_tokens: 30,
19573                cache_write_tokens: 40,
19574                reasoning_tokens: 50,
19575                context_window: Some(200_000),
19576            },
19577            lead_activity: ProviderActivity::WaitingForInput,
19578            activity: ProviderActivity::WaitingForInput,
19579            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19580            active_tools: (0..9)
19581                .map(|index| gate4agent_types::ActiveProviderTool {
19582                    id: format!("private-tool-id-{index}"),
19583                    name: if index == 0 {
19584                        format!("  Read\u{0000}{}", "x".repeat(80))
19585                    } else {
19586                        format!("Tool-{index}")
19587                    },
19588                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19589                })
19590                .collect(),
19591            interactions: vec![gate4agent_types::ProviderInteraction {
19592                id: gate4agent_types::ProviderInteractionId(17),
19593                source: source.clone(),
19594                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19595                interaction_kind: ProviderInteractionKind::Approval,
19596                tool_name: " Approve\u{0007}Tool ".to_owned(),
19597                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19598                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19599                resume_lead_activity: Some(ProviderActivity::Working),
19600                status: ProviderInteractionStatus::Pending,
19601            }],
19602            subagents: vec![gate4agent_types::ProviderSubagent {
19603                source: source.clone(),
19604                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19605                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19606                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19607            }],
19608            sources: vec![gate4agent_types::ProviderSourceCursor {
19609                source,
19610                sequence: 17,
19611                gap_count: 2,
19612                stale: true,
19613            }],
19614            last_event: Some(ProviderEvent::Error {
19615                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19616            }),
19617            gap_count: 2,
19618            stale: true,
19619        };
19620        let address = terminal_address(3);
19621        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19622        assert_eq!(entry.address, address);
19623        assert_eq!(entry.progress.provider_sequence, 17);
19624        assert_eq!(entry.progress.active_tool_count, 9);
19625        assert_eq!(
19626            entry.progress.active_tool_labels.len(),
19627            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19628        );
19629        assert_eq!(entry.progress.subagent_count, 1);
19630        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19631        assert!(entry.progress.truncated);
19632        let encoded = serde_json::to_string(&entry).unwrap();
19633        for forbidden in [
19634            "PRIVATE_PROVIDER_SESSION",
19635            "PRIVATE_PROVIDER_PATH",
19636            "PRIVATE_MODEL",
19637            "PRIVATE_TOOL_INVENTORY",
19638            "PRIVATE_CURRENT_PROMPT",
19639            "PRIVATE_TOOL_INPUT_JSON",
19640            "PRIVATE_PROVIDER_REQUEST",
19641            "PRIVATE_INTERACTION_PROMPT",
19642            "PRIVATE_INTERACTION_AGENT",
19643            "PRIVATE_SUBAGENT_ID",
19644            "PRIVATE_SUBAGENT_TYPE",
19645            "PRIVATE_SUBAGENT_DESCRIPTION",
19646            "PRIVATE_PROVIDER_ERROR",
19647            "input_json",
19648            "current_prompt",
19649            "session_id",
19650        ] {
19651            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19652        }
19653
19654        let snapshot = NodeSnapshot {
19655            node_id: NodeId::new("node-progress-test").unwrap(),
19656            enabled_providers: vec![agent("claude")],
19657            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19658            workspaces: Vec::new(),
19659            session_records: Vec::new(),
19660            managed_worktrees: Vec::new(),
19661            launch_inventory: None,
19662            agent_progress: vec![entry],
19663        };
19664        let mut legacy_reply = ResponseEnvelope {
19665            request_id: 1,
19666            result: Ok(NodeResponse::Snapshot {
19667                event_sequence: 0,
19668                controller: None,
19669                snapshot: snapshot.clone(),
19670            }),
19671        };
19672        project_response_without_agent_progress(&mut legacy_reply);
19673        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19674            panic!("agent progress projection changed response kind");
19675        };
19676        assert!(legacy.agent_progress.is_empty());
19677        assert_eq!(snapshot.agent_progress.len(), 1);
19678    }
19679
19680    fn terminal_address(generation: u64) -> SessionAddress {
19681        SessionAddress {
19682            workspace_id: WorkspaceId::new("primary").unwrap(),
19683            session: SessionKey {
19684                instance_id: AgentInstanceId(41),
19685                generation: SessionGeneration(generation),
19686            },
19687        }
19688    }
19689
19690    fn spawn_spec_fixture() -> SpawnSpec {
19691        SpawnSpec {
19692            target: crate::protocol::SpawnTarget {
19693                node_id: NodeId::new("node-terminal-test").unwrap(),
19694                workspace_id: WorkspaceId::new("primary").unwrap(),
19695                worktree_id: None,
19696            },
19697            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19698            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19699                .unwrap(),
19700            overrides: crate::protocol::SpawnOverrides::default(),
19701            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19702            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19703            required_capabilities: SpawnRequiredCapabilities::default(),
19704        }
19705    }
19706
19707    fn environment_profile_test_shared(
19708    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19709        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19710        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19711            "local-claude-r1",
19712        )
19713        .unwrap();
19714        let spawn_profiles = SpawnProfileRegistry::new([
19715            crate::protocol::SpawnProfileDefaults {
19716                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19717                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19718                provider: agent("claude"),
19719                mode: SessionMode::Pty,
19720                terminal_size: gate4agent_types::TerminalSize {
19721                    rows: 24,
19722                    columns: 80,
19723                },
19724                prompt: None,
19725                bundle_id: None,
19726                context_id: None,
19727                environment_profile_id: Some(profile_id.clone()),
19728            },
19729        ])
19730        .unwrap();
19731        let catalog = active_registry().unwrap();
19732        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19733        let control = runtime.native_launch_profile_control();
19734        let node_profile = NodeEnvironmentProfile::new(
19735            profile_id.clone(),
19736            profile_revision.clone(),
19737            agent("claude"),
19738            [NativeLaunchProfile::new(
19739                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19740                agent("claude"),
19741                TransportKind::Pty,
19742                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19743                Arc::new(EmptyEnvironmentResolver),
19744            )
19745            .unwrap()],
19746        )
19747        .unwrap();
19748        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19749        for native_profile in native_profiles {
19750            runtime.upsert_native_launch_profile(native_profile).unwrap();
19751        }
19752        let workspace = WorkspaceConfig::new(
19753            WorkspaceId::new("primary").unwrap(),
19754            std::env::current_dir().unwrap(),
19755        )
19756        .unwrap();
19757        let shared = NodeShared::new_with_incarnation(
19758            handle,
19759            "fixture-token".to_owned(),
19760            NodeId::new("node-terminal-test").unwrap(),
19761            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19762            vec![workspace],
19763            vec![agent("claude")],
19764            ProviderRuntimeStatuses::default(),
19765            None,
19766            Vec::new(),
19767            Vec::new(),
19768            spawn_profiles,
19769            Some(control),
19770            None,
19771            None,
19772            None,
19773            Vec::new(),
19774            Vec::new(),
19775            Vec::new(),
19776            Vec::new(),
19777            None,
19778            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19779        );
19780        shared
19781            .environment_profiles
19782            .write()
19783            .unwrap_or_else(|poisoned| poisoned.into_inner())
19784            .insert(binding.id.clone(), binding);
19785        (
19786            shared,
19787            runtime,
19788            ResolvedEnvironmentProfileReceipt {
19789                profile_id,
19790                profile_revision,
19791                network_allowlist: None,
19792                browser_profile_id: None,
19793            },
19794        )
19795    }
19796
19797    fn materialization_test_shared(
19798        deny_secret: bool,
19799    ) -> (
19800        NodeShared,
19801        NativeRuntime,
19802        ResolvedEnvironmentProfileReceipt,
19803        PathBuf,
19804        Arc<AtomicBool>,
19805    ) {
19806        let workspace = WorkspaceConfig::new(
19807            WorkspaceId::new("primary").unwrap(),
19808            std::env::current_dir().unwrap(),
19809        )
19810        .unwrap();
19811        materialization_test_shared_with_workspace(deny_secret, workspace)
19812    }
19813
19814    fn materialization_test_shared_with_workspace(
19815        deny_secret: bool,
19816        workspace: WorkspaceConfig,
19817    ) -> (
19818        NodeShared,
19819        NativeRuntime,
19820        ResolvedEnvironmentProfileReceipt,
19821        PathBuf,
19822        Arc<AtomicBool>,
19823    ) {
19824        let root = temporary_workspace_root(if deny_secret {
19825            "materialization-denied"
19826        } else {
19827            "materialization-ready"
19828        });
19829        std::fs::create_dir_all(&root).unwrap();
19830        let materialization_root = root.join("session-environments");
19831        let deny = Arc::new(AtomicBool::new(deny_secret));
19832        let resolver = Arc::new(FixtureSecretResolver {
19833            deny: Arc::clone(&deny),
19834        });
19835        let materializer = SessionEnvironmentMaterializer::new(
19836            materialization_root,
19837            resolver,
19838        )
19839        .unwrap();
19840        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19841        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19842            "materialized-claude-r1",
19843        )
19844        .unwrap();
19845        let materialization = NodeSessionMaterializationProfile::new(
19846            vec![NodeSessionEnvironmentMutation::SetSecret {
19847                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19848                reference: NodeSecretReference::new("fixture-token").unwrap(),
19849            }],
19850            vec![NodeSessionPathBinding::new(
19851                "GATE4AGENT_PROVIDER_HOME",
19852                NodeSessionPathClass::ProviderHome,
19853            )
19854            .unwrap()],
19855            vec![NodeSessionFile::secret(
19856                NodeSessionPathClass::Config,
19857                "auth/token",
19858                NodeSecretReference::new("fixture-token").unwrap(),
19859            )
19860            .unwrap()],
19861        )
19862        .unwrap();
19863        let catalog = active_registry().unwrap();
19864        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19865        let control = runtime.native_launch_profile_control();
19866        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19867            profile_id.clone(),
19868            profile_revision.clone(),
19869            agent("claude"),
19870            [NativeLaunchProfile::new(
19871                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19872                agent("claude"),
19873                TransportKind::Pty,
19874                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19875                Arc::new(EmptyEnvironmentResolver),
19876            )
19877            .unwrap()],
19878            Some(materialization),
19879        )
19880        .unwrap();
19881        let (binding, native_profiles, materialization) = node_profile.into_parts();
19882        for native_profile in native_profiles {
19883            runtime.upsert_native_launch_profile(native_profile).unwrap();
19884        }
19885        let shared = NodeShared::new_with_incarnation(
19886            handle,
19887            "fixture-token".to_owned(),
19888            NodeId::new("node-materialization-test").unwrap(),
19889            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19890            vec![workspace],
19891            vec![agent("claude")],
19892            ProviderRuntimeStatuses::default(),
19893            None,
19894            Vec::new(),
19895            Vec::new(),
19896            SpawnProfileRegistry::default(),
19897            Some(control),
19898            None,
19899            Some(materializer),
19900            None,
19901            Vec::new(),
19902            Vec::new(),
19903            Vec::new(),
19904            Vec::new(),
19905            None,
19906            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19907        );
19908        shared
19909            .environment_profiles
19910            .write()
19911            .unwrap_or_else(|poisoned| poisoned.into_inner())
19912            .insert(binding.id.clone(), binding);
19913        shared
19914            .environment_materialization_profiles
19915            .write()
19916            .unwrap_or_else(|poisoned| poisoned.into_inner())
19917            .insert(profile_id.clone(), materialization.unwrap());
19918        (
19919            shared,
19920            runtime,
19921            ResolvedEnvironmentProfileReceipt {
19922                profile_id,
19923                profile_revision,
19924                network_allowlist: None,
19925                browser_profile_id: None,
19926            },
19927            root,
19928            deny,
19929        )
19930    }
19931
19932    #[cfg(feature = "fixture")]
19933    #[derive(Clone, Copy)]
19934    enum CodexHomeProfile {
19935        Missing,
19936        WrongClass,
19937        Exact,
19938    }
19939
19940    #[cfg(feature = "fixture")]
19941    fn codex_bundle_test_shared(
19942        home_profile: CodexHomeProfile,
19943    ) -> (
19944        NodeShared,
19945        NativeRuntime,
19946        ResolvedEnvironmentProfileReceipt,
19947        NodeBundle,
19948        PathBuf,
19949    ) {
19950        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19951        const CLAUDE_MANIFEST: &[u8] =
19952            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19953        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19954        const DIGEST: &str =
19955            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19956
19957        let (shared, mut runtime, _claude_receipt, root, _deny) =
19958            materialization_test_shared(false);
19959        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19960            CodexHomeProfile::Missing => "codex-home-missing",
19961            CodexHomeProfile::WrongClass => "codex-home-wrong",
19962            CodexHomeProfile::Exact => "codex-home-exact",
19963        })
19964        .unwrap();
19965        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19966            "codex-home-r1",
19967        )
19968        .unwrap();
19969        let materialization = match home_profile {
19970            CodexHomeProfile::Missing => None,
19971            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19972                Some(NodeSessionMaterializationProfile::new(
19973                    Vec::new(),
19974                    vec![NodeSessionPathBinding::new(
19975                        "CODEX_HOME",
19976                        if matches!(home_profile, CodexHomeProfile::Exact) {
19977                            NodeSessionPathClass::ProviderHome
19978                        } else {
19979                            NodeSessionPathClass::Config
19980                        },
19981                    )
19982                    .unwrap()],
19983                    Vec::new(),
19984                )
19985                .unwrap())
19986            }
19987        };
19988        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19989            profile_id.clone(),
19990            profile_revision.clone(),
19991            agent("codex"),
19992            [NativeLaunchProfile::new(
19993                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19994                agent("codex"),
19995                TransportKind::Pty,
19996                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19997                Arc::new(EmptyEnvironmentResolver),
19998            )
19999            .unwrap()],
20000            materialization,
20001        )
20002        .unwrap();
20003        let (binding, native_profiles, materialization) = node_profile.into_parts();
20004        for native_profile in native_profiles {
20005            runtime.upsert_native_launch_profile(native_profile).unwrap();
20006        }
20007        shared
20008            .environment_profiles
20009            .write()
20010            .unwrap_or_else(|poisoned| poisoned.into_inner())
20011            .insert(binding.id.clone(), binding);
20012        if let Some(materialization) = materialization {
20013            shared
20014                .environment_materialization_profiles
20015                .write()
20016                .unwrap_or_else(|poisoned| poisoned.into_inner())
20017                .insert(profile_id.clone(), materialization);
20018        }
20019
20020        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20021        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20022        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20023        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20024        std::fs::write(
20025            bundle_source.join(".claude-plugin/plugin.json"),
20026            CLAUDE_MANIFEST,
20027        )
20028        .unwrap();
20029        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20030        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20031        let bundle = NodeBundle::new(
20032            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20033            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20034            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20035            &bundle_source,
20036        )
20037        .unwrap();
20038        *shared
20039            .bundle_catalog
20040            .write()
20041            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20042            BundleCatalog::new([bundle.clone()]).unwrap();
20043        (
20044            shared,
20045            runtime,
20046            ResolvedEnvironmentProfileReceipt {
20047                profile_id,
20048                profile_revision,
20049                network_allowlist: None,
20050                browser_profile_id: None,
20051            },
20052            bundle,
20053            root,
20054        )
20055    }
20056
20057    #[cfg(feature = "fixture")]
20058    #[test]
20059    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20060        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20061            let (shared, runtime, receipt, bundle, root) =
20062                codex_bundle_test_shared(home_profile);
20063            let error = shared
20064                .bundle_layout(
20065                    &agent("codex"),
20066                    SessionMode::Pty,
20067                    Some(&receipt),
20068                    &bundle,
20069                )
20070                .unwrap_err();
20071            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20072            assert!(shared
20073                .materializations
20074                .lock()
20075                .unwrap_or_else(|poisoned| poisoned.into_inner())
20076                .is_empty());
20077            let materialization_root = root.join("session-environments");
20078            assert!(!std::fs::read_dir(&materialization_root)
20079                .unwrap()
20080                .filter_map(Result::ok)
20081                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20082            drop(shared);
20083            drop(runtime);
20084            let _ = std::fs::remove_dir_all(root);
20085        }
20086    }
20087
20088    #[cfg(feature = "fixture")]
20089    #[test]
20090    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20091        let (shared, runtime, receipt, bundle, root) =
20092            codex_bundle_test_shared(CodexHomeProfile::Exact);
20093        let address = SessionAddress {
20094            workspace_id: WorkspaceId::new("primary").unwrap(),
20095            session: SessionKey {
20096                instance_id: AgentInstanceId(310),
20097                generation: SessionGeneration(1),
20098            },
20099        };
20100        let (overlay, guard) = shared
20101            .prepare_session_materialization(
20102                &address,
20103                &agent("codex"),
20104                SessionMode::Pty,
20105                Some(&receipt),
20106                Some(&bundle.receipt()),
20107                None,
20108                None,
20109            )
20110            .unwrap()
20111            .unwrap();
20112        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20113        let ownership = shared
20114            .materializations
20115            .lock()
20116            .unwrap_or_else(|poisoned| poisoned.into_inner())
20117            .get(guard.id().unwrap())
20118            .unwrap()
20119            .clone();
20120        let (profile, _, _) = shared
20121            .materialization_profile(
20122                &agent("codex"),
20123                SessionMode::Pty,
20124                Some(&receipt),
20125                Some(&bundle.receipt()),
20126                None,
20127            )
20128            .unwrap()
20129            .unwrap();
20130        let environment = shared
20131            .session_environment_materializer
20132            .as_ref()
20133            .unwrap()
20134            .resolve_environment(&ownership, &profile)
20135            .unwrap();
20136        assert!(environment.iter().any(|mutation| {
20137            mutation.key == OsString::from("CODEX_HOME")
20138                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20139        }));
20140        assert_eq!(
20141            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20142                .unwrap(),
20143            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20144        );
20145        assert!(std::fs::read_dir(ownership.bundle_root())
20146            .unwrap()
20147            .next()
20148            .is_none());
20149        drop(guard);
20150        drop(shared);
20151        drop(runtime);
20152        let _ = std::fs::remove_dir_all(root);
20153    }
20154
20155    #[cfg(feature = "fixture")]
20156    #[test]
20157    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20158        let (shared, runtime, receipt, bundle, root) =
20159            codex_bundle_test_shared(CodexHomeProfile::Exact);
20160        let bundle_receipt = bundle.receipt();
20161        let address = SessionAddress {
20162            workspace_id: WorkspaceId::new("primary").unwrap(),
20163            session: SessionKey {
20164                instance_id: AgentInstanceId(311),
20165                generation: SessionGeneration(1),
20166            },
20167        };
20168        let (_overlay, guard) = shared
20169            .prepare_session_materialization(
20170                &address,
20171                &agent("codex"),
20172                SessionMode::Pty,
20173                Some(&receipt),
20174                Some(&bundle_receipt),
20175                None,
20176                None,
20177            )
20178            .unwrap()
20179            .unwrap();
20180        let materialization_id = guard.id().unwrap().clone();
20181        let skill_path = shared
20182            .materializations
20183            .lock()
20184            .unwrap_or_else(|poisoned| poisoned.into_inner())
20185            .get(&materialization_id)
20186            .unwrap()
20187            .provider_home()
20188            .join("skills/review-code/SKILL.md");
20189        let record_id = shared
20190            .bind_spawn_session_with_materialization(
20191                &address,
20192                agent("codex"),
20193                SessionMode::Pty,
20194                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20195                SpawnRecordPolicy::ProviderIdentityOnly,
20196                Some(receipt.clone()),
20197                Some(bundle_receipt.clone()),
20198                None,
20199                Some(materialization_id.clone()),
20200            )
20201            .unwrap()
20202            .unwrap();
20203        guard.retain();
20204        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20205
20206        let error = match shared.resolve_record_materialization(
20207                &record_id,
20208                &agent("codex"),
20209                SessionMode::Pty,
20210                Some(&receipt),
20211                Some(&bundle_receipt),
20212                None,
20213            ) {
20214                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20215                Err(error) => error,
20216            };
20217        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20218        assert_eq!(
20219            shared
20220                .materializations
20221                .lock()
20222                .unwrap_or_else(|poisoned| poisoned.into_inner())
20223                .get(&materialization_id)
20224                .unwrap()
20225                .state(),
20226            MaterializationState::RecoveryRequired,
20227        );
20228        drop(shared);
20229        drop(runtime);
20230        let _ = std::fs::remove_dir_all(root);
20231    }
20232
20233    fn bind_record_owned_materialization_fixture(
20234        shared: &NodeShared,
20235        receipt: &ResolvedEnvironmentProfileReceipt,
20236        instance_id: u64,
20237    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20238        let address = SessionAddress {
20239            workspace_id: WorkspaceId::new("primary").unwrap(),
20240            session: SessionKey {
20241                instance_id: AgentInstanceId(instance_id),
20242                generation: SessionGeneration(1),
20243            },
20244        };
20245        let (overlay, materialization_guard) = shared
20246            .prepare_session_materialization(
20247                &address,
20248                &agent("claude"),
20249                SessionMode::Pty,
20250                Some(receipt),
20251                None,
20252                None,
20253                None,
20254            )
20255            .unwrap()
20256            .unwrap();
20257        let materialization_id = materialization_guard.id().unwrap().clone();
20258        let materialization_root = shared
20259            .materializations
20260            .lock()
20261            .unwrap_or_else(|poisoned| poisoned.into_inner())
20262            .get(&materialization_id)
20263            .unwrap()
20264            .root()
20265            .to_path_buf();
20266        let selection = shared
20267            .select_environment_profile(
20268                address.session.instance_id,
20269                &agent("claude"),
20270                SessionMode::Pty,
20271                Some(receipt),
20272            )
20273            .unwrap()
20274            .unwrap();
20275        assert!(shared
20276            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20277            .unwrap()
20278            .is_none());
20279        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20280        let record_id = shared
20281            .bind_spawn_session_with_materialization(
20282                &address,
20283                agent("claude"),
20284                SessionMode::Pty,
20285                policy,
20286                SpawnRecordPolicy::ProviderIdentityOnly,
20287                Some(receipt.clone()),
20288                None,
20289                None,
20290                Some(materialization_id.clone()),
20291            )
20292            .unwrap()
20293            .unwrap();
20294        selection.retain();
20295        materialization_guard.retain();
20296        (address, record_id, materialization_id, materialization_root)
20297    }
20298
20299    #[test]
20300    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20301        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20302        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20303            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20304        }));
20305        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20306        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20307        let mut spec = spawn_spec_fixture();
20308        spec.expected_profile_revision =
20309            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20310        assert!(matches!(
20311            &spec.overrides.environment_profile_id,
20312            crate::protocol::SpawnOverride::Inherit,
20313        ));
20314        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20315            .requires_child_environment_profile_capability());
20316        assert!(request_requires_child_environment_profile(
20317            &shared,
20318            &NodeRequest::SpawnSpec { spec: spec.clone() },
20319        ));
20320
20321        let address = terminal_address(1);
20322        shared.bind_session_with_policy(
20323            &address,
20324            ProviderRuntimePolicy::raw_pty(),
20325            Some(environment_profile.clone()),
20326        );
20327        assert!(request_requires_child_environment_profile(
20328            &shared,
20329            &NodeRequest::Input {
20330                session: address.clone(),
20331                text: "x".to_owned(),
20332            },
20333        ));
20334
20335        let mut record = record("claude", "profile-record");
20336        record.active_session = Some(address.clone());
20337        record.environment_profile = Some(environment_profile.clone());
20338        let record_id = record.record_id.clone();
20339        shared.insert_record(record.clone()).unwrap();
20340        assert!(request_requires_child_environment_profile(
20341            &shared,
20342            &NodeRequest::ResumeSessionRecord {
20343                record_id,
20344                terminal_size: gate4agent_types::TerminalSize {
20345                    rows: 24,
20346                    columns: 80,
20347                },
20348                initial_prompt: None,
20349            },
20350        ));
20351
20352        let snapshot = snapshot_for_wire(
20353            &shared, false, true, true, true, false, false, false, false, true,
20354        );
20355        assert_eq!(snapshot.session_records.len(), 1);
20356        assert!(snapshot.session_records[0].environment_profile.is_none());
20357        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20358            sequence: 1,
20359            event: NodeEvent::SessionRecordUpserted { record },
20360        });
20361        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20362            panic!("record projection changed the event kind");
20363        };
20364        assert!(record.environment_profile.is_none());
20365
20366        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20367        let mut reply = ResponseEnvelope {
20368            request_id: 1,
20369            result: Ok(NodeResponse::SpawnSpecAccepted {
20370                receipt: resolved.receipt_with_environment(
20371                    shared.incarnation_id,
20372                    address,
20373                    Some(environment_profile),
20374                ),
20375            }),
20376        };
20377        project_response_without_child_environment_profile(&mut reply);
20378        assert_eq!(
20379            reply.result.unwrap_err().code,
20380            NodeFailureCode::UnsupportedCapability,
20381        );
20382    }
20383
20384    #[test]
20385    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20386        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20387        let snapshot = shared.snapshot();
20388        let profiles = snapshot
20389            .launch_inventory
20390            .as_ref()
20391            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20392            .unwrap();
20393        assert_eq!(profiles.len(), 1);
20394        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20395        assert!(snapshot.requires_child_environment_profile_capability());
20396
20397        let mut legacy = snapshot.clone();
20398        clear_snapshot_child_environment_profiles(&mut legacy);
20399        assert!(legacy
20400            .launch_inventory
20401            .as_ref()
20402            .unwrap()
20403            .spawn_profiles
20404            .as_ref()
20405            .unwrap()[0]
20406            .environment_profile
20407            .is_none());
20408        assert!(!legacy.requires_child_environment_profile_capability());
20409
20410        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20411        let bindings = shared
20412            .environment_profiles
20413            .read()
20414            .unwrap_or_else(|poisoned| poisoned.into_inner())
20415            .clone();
20416        let mut provider_mismatch = bindings.clone();
20417        provider_mismatch
20418            .values_mut()
20419            .next()
20420            .unwrap()
20421            .provider = agent("codex");
20422        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20423        let mut mode_mismatch = profile;
20424        mode_mismatch.mode = SessionMode::Inline;
20425        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20426
20427        shared
20428            .environment_profiles
20429            .write()
20430            .unwrap_or_else(|poisoned| poisoned.into_inner())
20431            .clear();
20432        assert!(shared
20433            .snapshot()
20434            .launch_inventory
20435            .unwrap()
20436            .spawn_profiles
20437            .unwrap()
20438            .is_empty());
20439    }
20440
20441    #[test]
20442    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20443        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20444            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20445        }));
20446        let shared = terminal_test_shared();
20447        let bundle = ResolvedBundleReceipt {
20448            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20449            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20450            digest: crate::protocol::SpawnBundleDigest::new(format!(
20451                "sha256:{}",
20452                "0".repeat(64),
20453            ))
20454            .unwrap(),
20455        };
20456        let mut bundled = record("claude", "bundle-record");
20457        bundled.bundle = Some(bundle.clone());
20458        let record_id = bundled.record_id.clone();
20459        shared.insert_record(bundled.clone()).unwrap();
20460        assert!(request_requires_session_bundle(
20461            &shared,
20462            &NodeRequest::ForgetSessionRecord {
20463                record_id: record_id.clone(),
20464            },
20465        ));
20466
20467        let snapshot = snapshot_for_wire(
20468            &shared, false, true, true, true, true, false, false, false, true,
20469        );
20470        assert!(snapshot.session_records[0].bundle.is_none());
20471        let launch = snapshot.launch_inventory.as_ref().unwrap();
20472        assert!(launch.spawn_profiles.is_some());
20473        assert!(launch.bundles.is_none());
20474        let event = project_event_without_session_bundle(NodeEventEnvelope {
20475            sequence: 1,
20476            event: NodeEvent::SessionRecordUpserted { record: bundled },
20477        });
20478        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20479            panic!("record projection changed the event kind");
20480        };
20481        assert!(record.bundle.is_none());
20482
20483        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20484        let mut reply = ResponseEnvelope {
20485            request_id: 1,
20486            result: Ok(NodeResponse::SpawnSpecAccepted {
20487                receipt: resolved.receipt_with_materialization(
20488                    shared.incarnation_id,
20489                    terminal_address(17),
20490                    None,
20491                    Some(bundle),
20492                    None,
20493                ),
20494            }),
20495        };
20496        project_response_without_session_bundle(&mut reply);
20497        assert_eq!(
20498            reply.result.unwrap_err().code,
20499            NodeFailureCode::UnsupportedCapability,
20500        );
20501    }
20502
20503    #[test]
20504    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20505        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20506            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20507        }));
20508        let context_id = SpawnContextId::new("context-review").unwrap();
20509        let context = ResolvedContextPackReceipt {
20510            id: context_id.clone(),
20511            digest: crate::protocol::SpawnContextDigest::new(format!(
20512                "sha256:{}",
20513                "c".repeat(64),
20514            ))
20515            .unwrap(),
20516            lineage: ContextPackLineageReceipt {
20517                source_node_id: NodeId::new("node-source").unwrap(),
20518                source_session: terminal_address(1),
20519                source_provider: agent("kimi"),
20520            },
20521            source_message_count: 2,
20522            retained_message_count: 2,
20523            byte_len: 256,
20524            truncated: false,
20525        };
20526        assert!(context.is_valid());
20527        let mut bound_record = record("codex", "context-record");
20528        bound_record.context_id = Some(context_id);
20529        bound_record.context = Some(context.clone());
20530        let session = gate4agent_types::SessionSnapshot {
20531            instance_id: AgentInstanceId(91),
20532            agent_id: agent("kimi"),
20533            transport: TransportKind::Pty,
20534            generation: SessionGeneration(3),
20535            status: SessionStatus::Running,
20536            pending_operation: None,
20537            pending_input: None,
20538            process_id: Some(9001),
20539            terminal_size: None,
20540            terminal_frame: None,
20541            terminal_stale: None,
20542            session_options: None,
20543            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20544            history: gate4agent_types::HistorySnapshot {
20545                pending: None,
20546                candidates: vec![HistoryCandidateSummary {
20547                    id: "candidate-opaque".to_owned(),
20548                    session_id_hint: "session-hint".to_owned(),
20549                    modified_at_unix_ms: Some(1),
20550                }],
20551                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20552                loaded: Some(HistorySessionRecord {
20553                    session_id: "private-vendor-session".to_owned(),
20554                    title: Some("private title".to_owned()),
20555                    cwd: Some(r"C:\private\history-root".to_owned()),
20556                    model: Some("private-model".to_owned()),
20557                    message_count: 2,
20558                    completed_turn_count: None,
20559                    total_tokens: 19,
20560                    messages: vec![
20561                        gate4agent_types::HistoryMessageRecord {
20562                            role: gate4agent_types::HistoryMessageRole::User,
20563                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20564                        },
20565                        gate4agent_types::HistoryMessageRecord {
20566                            role: gate4agent_types::HistoryMessageRole::Assistant,
20567                            text: "private answer".to_owned(),
20568                        },
20569                    ],
20570                }),
20571                last_error: None,
20572            },
20573            resume: gate4agent_types::ResumeSnapshot::default(),
20574            foreground: gate4agent_types::ForegroundSnapshot::default(),
20575            provider: gate4agent_types::ProviderSnapshot::default(),
20576            screen_state: gate4agent_types::PtyScreenState::default(),
20577        };
20578        let original = NodeSnapshot {
20579            node_id: NodeId::new("node-terminal-test").unwrap(),
20580            enabled_providers: vec![agent("kimi"), agent("codex")],
20581            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20582            workspaces: vec![WorkspaceSnapshot {
20583                workspace_id: WorkspaceId::new("primary").unwrap(),
20584                canonical_root: opaque_windows_path(
20585                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20586                ),
20587                sessions: vec![session],
20588                worktree_service_mode: None,
20589                managed_worktree_profiles: None,
20590            }],
20591            session_records: vec![bound_record],
20592            managed_worktrees: Vec::new(),
20593            launch_inventory: None,
20594            agent_progress: Vec::new(),
20595        };
20596
20597        let mut negotiated = original.clone();
20598        project_snapshot_history_for_wire(&mut negotiated, true);
20599        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20600        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20601        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20602        assert!(!serde_json::to_string(&negotiated)
20603            .unwrap()
20604            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20605
20606        let mut legacy = original;
20607        project_snapshot_history_for_wire(&mut legacy, false);
20608        clear_snapshot_context_packs(&mut legacy);
20609        assert_eq!(
20610            legacy.workspaces[0].sessions[0].history,
20611            gate4agent_types::HistorySnapshot::default(),
20612        );
20613        assert!(legacy.session_records[0].context_id.is_none());
20614        assert!(legacy.session_records[0].context.is_none());
20615        let encoded = serde_json::to_string(&legacy).unwrap();
20616        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20617        assert!(!encoded.contains("context-review"));
20618
20619        let mut reply = ResponseEnvelope {
20620            request_id: 7,
20621            result: Ok(NodeResponse::ContextPackExported { context }),
20622        };
20623        project_response_without_context_pack(&mut reply);
20624        assert_eq!(
20625            reply.result.unwrap_err().code,
20626            NodeFailureCode::UnsupportedCapability,
20627        );
20628    }
20629
20630    #[test]
20631    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20632        let shared = terminal_test_shared();
20633        let history = HistorySessionRecord {
20634            session_id: "fixture-history".to_owned(),
20635            title: None,
20636            cwd: None,
20637            model: None,
20638            message_count: 1,
20639            completed_turn_count: None,
20640            total_tokens: 0,
20641            messages: vec![gate4agent_types::HistoryMessageRecord {
20642                role: gate4agent_types::HistoryMessageRole::User,
20643                text: "bounded handoff".to_owned(),
20644            }],
20645        };
20646        let pack = NodeContextPack::export(
20647            ContextPackLineageReceipt {
20648                source_node_id: shared.node_id.clone(),
20649                source_session: terminal_address(1),
20650                source_provider: agent("claude"),
20651            },
20652            &history,
20653        )
20654        .unwrap();
20655        let context = shared
20656            .context_catalog
20657            .write()
20658            .unwrap_or_else(|poisoned| poisoned.into_inner())
20659            .insert(pack)
20660            .unwrap();
20661        let mut retained = record("codex", "context-retained-record");
20662        retained.context_id = Some(context.id.clone());
20663        retained.context = Some(context.clone());
20664        let record_id = retained.record_id.clone();
20665        shared.insert_record(retained).unwrap();
20666        assert_eq!(
20667            shared.forget_context_pack(&context.id).unwrap_err().code,
20668            NodeFailureCode::ContextPackBusy,
20669        );
20670        assert!(shared
20671            .context_catalog
20672            .read()
20673            .unwrap_or_else(|poisoned| poisoned.into_inner())
20674            .get(&context.id)
20675            .is_some());
20676        shared
20677            .session_records
20678            .lock()
20679            .unwrap_or_else(|poisoned| poisoned.into_inner())
20680            .records
20681            .remove(&record_id);
20682
20683        let mut spec = spawn_spec_fixture();
20684        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20685            value: context.id.clone(),
20686        };
20687        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20688        let receipt = resolved.receipt_with_materialization(
20689            shared.incarnation_id,
20690            terminal_address(2),
20691            None,
20692            None,
20693            Some(context.clone()),
20694        );
20695        assert!(receipt.context_binding_is_valid());
20696        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20697
20698        shared.forget_context_pack(&context.id).unwrap();
20699        assert!(shared
20700            .context_catalog
20701            .read()
20702            .unwrap_or_else(|poisoned| poisoned.into_inner())
20703            .get(&context.id)
20704            .is_none());
20705        assert!(shared
20706            .replay_spawn_spec(&spec, Instant::now())
20707            .unwrap()
20708            .is_none());
20709    }
20710
20711    #[test]
20712    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20713        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20714        let address = terminal_address(1);
20715        let selection = shared
20716            .select_environment_profile(
20717                address.session.instance_id,
20718                &agent("claude"),
20719                SessionMode::Pty,
20720                Some(&environment_profile),
20721            )
20722            .unwrap()
20723            .unwrap();
20724        shared.bind_session_with_policy(
20725            &address,
20726            ProviderRuntimePolicy::raw_pty(),
20727            Some(environment_profile.clone()),
20728        );
20729        selection.retain();
20730        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20731        assert!(matches!(
20732            runtime.remove_native_launch_profile(&native_id),
20733            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20734        ));
20735        assert!(shared.remove_binding(&address).is_some());
20736        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20737
20738        let mismatched = ResolvedEnvironmentProfileReceipt {
20739            profile_id: environment_profile.profile_id,
20740            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20741                "local-claude-r2",
20742            )
20743            .unwrap(),
20744            network_allowlist: None,
20745            browser_profile_id: None,
20746        };
20747        let error = match shared.select_environment_profile(
20748                AgentInstanceId(42),
20749                &agent("claude"),
20750                SessionMode::Pty,
20751                Some(&mismatched),
20752            ) {
20753            Ok(_) => panic!("mismatched environment profile revision was selected"),
20754            Err(error) => error,
20755        };
20756        assert_eq!(
20757            error.code,
20758            NodeFailureCode::EnvironmentProfileBindingMismatch,
20759        );
20760        assert!(!shared
20761            .session_bindings
20762            .lock()
20763            .unwrap_or_else(|poisoned| poisoned.into_inner())
20764            .contains_key(&AgentInstanceId(42)));
20765    }
20766
20767    #[test]
20768    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20769        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20770        let address = terminal_address(1);
20771        let selection = shared
20772            .select_environment_profile(
20773                address.session.instance_id,
20774                &agent("claude"),
20775                SessionMode::Pty,
20776                Some(&environment_profile),
20777            )
20778            .unwrap()
20779            .unwrap();
20780        let overlay = NativeInstanceLaunchOverlay::new(
20781            agent("claude"),
20782            TransportKind::Pty,
20783            vec![EnvMutation {
20784                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20785                value: Some(OsString::from("fixture-secret")),
20786            }],
20787            vec![
20788                OsString::from("--plugin-dir"),
20789                std::env::current_dir().unwrap().into_os_string(),
20790            ],
20791        )
20792        .unwrap();
20793        let instance_overlay = shared
20794            .install_prepared_launch_overlay(
20795                address.session.instance_id,
20796                PreparedNativeLaunchOverlay::Instance(overlay),
20797            )
20798            .unwrap()
20799            .unwrap();
20800        let bundle = ResolvedBundleReceipt {
20801            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20802            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20803            digest: crate::protocol::SpawnBundleDigest::new(format!(
20804                "sha256:{}",
20805                "0".repeat(64),
20806            ))
20807            .unwrap(),
20808        };
20809        shared.bind_session_with_materialization(
20810            &address,
20811            ProviderRuntimePolicy::raw_pty(),
20812            Some(environment_profile),
20813            Some(bundle),
20814            None,
20815            None,
20816        );
20817        selection.retain();
20818        instance_overlay.retain();
20819
20820        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20821        assert!(matches!(
20822            runtime.remove_native_launch_profile(&native_id),
20823            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20824        ));
20825        assert!(shared.remove_binding(&address).is_some());
20826        assert!(!shared
20827            .native_launch_profile_control
20828            .as_ref()
20829            .unwrap()
20830            .clear_native_instance_launch_overlay(address.session.instance_id));
20831        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20832    }
20833
20834    #[test]
20835    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20836        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20837        let address = SessionAddress {
20838            workspace_id: WorkspaceId::new("primary").unwrap(),
20839            session: SessionKey {
20840                instance_id: AgentInstanceId(201),
20841                generation: SessionGeneration(1),
20842            },
20843        };
20844        let error = match shared.prepare_session_materialization(
20845            &address,
20846            &agent("claude"),
20847            SessionMode::Pty,
20848            Some(&receipt),
20849            None,
20850            None,
20851            None,
20852        ) {
20853            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20854            Err(error) => error,
20855        };
20856        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20857        assert!(shared.handle.snapshot().sessions.is_empty());
20858        assert!(shared
20859            .session_bindings
20860            .lock()
20861            .unwrap_or_else(|poisoned| poisoned.into_inner())
20862            .is_empty());
20863        assert!(shared
20864            .materializations
20865            .lock()
20866            .unwrap_or_else(|poisoned| poisoned.into_inner())
20867            .is_empty());
20868        drop(shared);
20869        drop(runtime);
20870        std::fs::remove_dir_all(root).unwrap();
20871    }
20872
20873    #[test]
20874    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20875        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20876        let address = SessionAddress {
20877            workspace_id: WorkspaceId::new("primary").unwrap(),
20878            session: SessionKey {
20879                instance_id: AgentInstanceId(202),
20880                generation: SessionGeneration(1),
20881            },
20882        };
20883        let (overlay, materialization_guard) = shared
20884            .prepare_session_materialization(
20885                &address,
20886                &agent("claude"),
20887                SessionMode::Pty,
20888                Some(&receipt),
20889                None,
20890                None,
20891                None,
20892            )
20893            .unwrap()
20894            .unwrap();
20895        let materialization_id = materialization_guard.id().unwrap().clone();
20896        let materialization_root = shared
20897            .materializations
20898            .lock()
20899            .unwrap_or_else(|poisoned| poisoned.into_inner())
20900            .get(&materialization_id)
20901            .unwrap()
20902            .root()
20903            .to_path_buf();
20904        let selection = shared
20905            .select_environment_profile(
20906                address.session.instance_id,
20907                &agent("claude"),
20908                SessionMode::Pty,
20909                Some(&receipt),
20910            )
20911            .unwrap()
20912            .unwrap();
20913        assert!(shared
20914            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20915            .unwrap()
20916            .is_none());
20917        shared.bind_session_with_materialization(
20918            &address,
20919            ProviderRuntimePolicy::raw_pty(),
20920            Some(receipt),
20921            None,
20922            None,
20923            Some(materialization_id),
20924        );
20925        selection.retain();
20926        materialization_guard.retain();
20927
20928        assert!(materialization_root.is_dir());
20929        assert!(shared
20930            .session_bindings
20931            .lock()
20932            .unwrap_or_else(|poisoned| poisoned.into_inner())
20933            .contains_key(&address.session.instance_id));
20934
20935        let removed = shared.remove_binding(&address).unwrap();
20936        shared
20937            .cleanup_session_owned_materialization(&address, &removed)
20938            .unwrap();
20939        assert!(!materialization_root.exists());
20940        assert!(shared
20941            .materializations
20942            .lock()
20943            .unwrap_or_else(|poisoned| poisoned.into_inner())
20944            .is_empty());
20945        drop(shared);
20946        drop(runtime);
20947        std::fs::remove_dir_all(root).unwrap();
20948    }
20949
20950    #[tokio::test]
20951    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20952        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20953        let address = SessionAddress {
20954            workspace_id: WorkspaceId::new("primary").unwrap(),
20955            session: SessionKey {
20956                instance_id: AgentInstanceId(203),
20957                generation: SessionGeneration(1),
20958            },
20959        };
20960        let (overlay, materialization_guard) = shared
20961            .prepare_session_materialization(
20962                &address,
20963                &agent("claude"),
20964                SessionMode::Pty,
20965                Some(&receipt),
20966                None,
20967                None,
20968                None,
20969            )
20970            .unwrap()
20971            .unwrap();
20972        let materialization_id = materialization_guard.id().unwrap().clone();
20973        let materialization_root = shared
20974            .materializations
20975            .lock()
20976            .unwrap_or_else(|poisoned| poisoned.into_inner())
20977            .get(&materialization_id)
20978            .unwrap()
20979            .root()
20980            .to_path_buf();
20981        let selection = shared
20982            .select_environment_profile(
20983                address.session.instance_id,
20984                &agent("claude"),
20985                SessionMode::Pty,
20986                Some(&receipt),
20987            )
20988            .unwrap()
20989            .unwrap();
20990        assert!(shared
20991            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20992            .unwrap()
20993            .is_none());
20994        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20995        let record_id = shared
20996            .bind_spawn_session_with_materialization(
20997                &address,
20998                agent("claude"),
20999                SessionMode::Pty,
21000                policy,
21001                SpawnRecordPolicy::ProviderIdentityOnly,
21002                Some(receipt.clone()),
21003                None,
21004                None,
21005                Some(materialization_id.clone()),
21006            )
21007            .unwrap()
21008            .unwrap();
21009        selection.retain();
21010        materialization_guard.retain();
21011        assert_eq!(
21012            shared
21013                .materializations
21014                .lock()
21015                .unwrap_or_else(|poisoned| poisoned.into_inner())
21016                .get(&materialization_id)
21017                .unwrap()
21018                .owner(),
21019            &MaterializationOwner::Record {
21020                record_id: record_id.clone(),
21021            },
21022        );
21023
21024        let removed = shared.remove_binding(&address).unwrap();
21025        shared
21026            .cleanup_session_owned_materialization(&address, &removed)
21027            .unwrap();
21028        assert!(materialization_root.is_dir());
21029        assert!(shared
21030            .resolve_record_materialization(
21031                &record_id,
21032                &agent("claude"),
21033                SessionMode::Pty,
21034                Some(&receipt),
21035                None,
21036                None,
21037            )
21038            .unwrap()
21039            .is_some());
21040        {
21041            let mut records = shared
21042                .session_records
21043                .lock()
21044                .unwrap_or_else(|poisoned| poisoned.into_inner());
21045            let record = records.records.get_mut(&record_id).unwrap();
21046            record.active_session = None;
21047            record.state = ManagedSessionState::Unavailable;
21048        }
21049        shared.forget_session_record(&record_id).await.unwrap();
21050        assert!(!materialization_root.exists());
21051        assert!(shared
21052            .materializations
21053            .lock()
21054            .unwrap_or_else(|poisoned| poisoned.into_inner())
21055            .is_empty());
21056        drop(shared);
21057        drop(runtime);
21058        std::fs::remove_dir_all(root).unwrap();
21059    }
21060
21061    #[test]
21062    fn record_materialization_revalidation_failure_persists_recovery_required() {
21063        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21064        let state_path = root.join("node-state-v6.json");
21065        shared.state_path = Some(state_path.clone());
21066        let (_address, _record_id, materialization_id, materialization_root) =
21067            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21068        std::fs::write(
21069            materialization_root.join(".gate4agent-materialization-owner"),
21070            b"tampered",
21071        )
21072        .unwrap();
21073
21074        let error = match shared.resolve_record_materialization(
21075            &_record_id,
21076            &agent("claude"),
21077            SessionMode::Pty,
21078            Some(&receipt),
21079            None,
21080            None,
21081        ) {
21082            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21083            Err(error) => error,
21084        };
21085        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21086        assert_eq!(
21087            shared
21088                .materializations
21089                .lock()
21090                .unwrap_or_else(|poisoned| poisoned.into_inner())
21091                .get(&materialization_id)
21092                .unwrap()
21093                .state(),
21094            MaterializationState::RecoveryRequired,
21095        );
21096        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21097        assert_eq!(loaded.materializations.len(), 1);
21098        assert_eq!(
21099            loaded.materializations[0].state(),
21100            MaterializationState::RecoveryRequired,
21101        );
21102        drop(shared);
21103        drop(runtime);
21104        std::fs::remove_dir_all(root).unwrap();
21105    }
21106
21107    #[test]
21108    fn transient_record_materialization_resolution_failure_remains_ready() {
21109        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21110        let (_address, record_id, materialization_id, _materialization_root) =
21111            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21112        deny.store(true, Ordering::Release);
21113
21114        let error = match shared.resolve_record_materialization(
21115            &record_id,
21116            &agent("claude"),
21117            SessionMode::Pty,
21118            Some(&receipt),
21119            None,
21120            None,
21121        ) {
21122            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21123            Err(error) => error,
21124        };
21125        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21126        assert_eq!(
21127            shared
21128                .materializations
21129                .lock()
21130                .unwrap_or_else(|poisoned| poisoned.into_inner())
21131                .get(&materialization_id)
21132                .unwrap()
21133                .state(),
21134            MaterializationState::Ready,
21135        );
21136        drop(shared);
21137        drop(runtime);
21138        std::fs::remove_dir_all(root).unwrap();
21139    }
21140
21141    #[test]
21142    fn startup_missing_record_materialization_is_not_resumable() {
21143        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21144        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21145        shared
21146            .insert_record(ManagedSessionRecord {
21147                record_id: record_id.clone(),
21148                display_name: "missing materialization".to_owned(),
21149                provider: agent("claude"),
21150                mode: SessionMode::Pty,
21151                state: ManagedSessionState::Dormant,
21152                workspace_id: WorkspaceId::new("primary").unwrap(),
21153                canonical_root: opaque_windows_path(
21154                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21155                ),
21156                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21157                    key: gate4agent_types::ProviderSessionKey::SessionId,
21158                    id: "provider-session-missing-materialization".to_owned(),
21159                    transcript_path: None,
21160                }),
21161                active_session: None,
21162                environment_profile: Some(receipt),
21163                bundle: None,
21164                context_id: None,
21165                context: None,
21166                exported_context: None,
21167                task_binding: None,
21168                created_at_unix_ms: 1,
21169                updated_at_unix_ms: 1,
21170                last_error: None,
21171            })
21172            .unwrap();
21173
21174        shared.reconcile_materializations();
21175        let record = shared.record(&record_id).unwrap();
21176        assert_eq!(record.state, ManagedSessionState::Unavailable);
21177        assert!(record.provider_session.is_none());
21178        assert_eq!(
21179            record.last_error.as_deref(),
21180            Some("environment-profile-unavailable"),
21181        );
21182        drop(shared);
21183        drop(runtime);
21184        std::fs::remove_dir_all(root).unwrap();
21185    }
21186
21187    #[tokio::test]
21188    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21189        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21190        let lease = install_managed_lease(&shared);
21191        let address = SessionAddress {
21192            workspace_id: WorkspaceId::new("primary").unwrap(),
21193            session: SessionKey {
21194                instance_id: AgentInstanceId(204),
21195                generation: SessionGeneration(1),
21196            },
21197        };
21198        let (overlay, materialization_guard) = shared
21199            .prepare_session_materialization(
21200                &address,
21201                &agent("claude"),
21202                SessionMode::Pty,
21203                Some(&receipt),
21204                None,
21205                None,
21206                Some(lease.lease_id.clone()),
21207            )
21208            .unwrap()
21209            .unwrap();
21210        let materialization_id = materialization_guard.id().unwrap().clone();
21211        let materialization_root = shared
21212            .materializations
21213            .lock()
21214            .unwrap_or_else(|poisoned| poisoned.into_inner())
21215            .get(&materialization_id)
21216            .unwrap()
21217            .root()
21218            .to_path_buf();
21219        let selection = shared
21220            .select_environment_profile(
21221                address.session.instance_id,
21222                &agent("claude"),
21223                SessionMode::Pty,
21224                Some(&receipt),
21225            )
21226            .unwrap()
21227            .unwrap();
21228        assert!(shared
21229            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21230            .unwrap()
21231            .is_none());
21232        shared.bind_session_with_materialization(
21233            &address,
21234            ProviderRuntimePolicy::raw_pty(),
21235            Some(receipt),
21236            None,
21237            None,
21238            Some(materialization_id),
21239        );
21240        selection.retain();
21241        materialization_guard.retain();
21242
21243        let blocked = shared
21244            .cleanup_managed_worktree(&lease.lease_id, false)
21245            .await
21246            .unwrap_err();
21247        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21248        assert!(materialization_root.is_dir());
21249        let removed = shared.remove_binding(&address).unwrap();
21250        shared
21251            .cleanup_session_owned_materialization(&address, &removed)
21252            .unwrap();
21253        assert!(!materialization_root.exists());
21254        drop(shared);
21255        drop(runtime);
21256        std::fs::remove_dir_all(root).unwrap();
21257    }
21258
21259    #[tokio::test]
21260    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21261        let git_root = temporary_workspace_root("raw-managed-inventory");
21262        let source = git_root.join("source");
21263        let allocation = git_root.join("allocation");
21264        std::fs::create_dir_all(&source).unwrap();
21265        std::fs::create_dir_all(&allocation).unwrap();
21266        run_test_git(&source, &["init"]);
21267        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21268        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21269        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21270        run_test_git(&source, &["add", "seed.txt"]);
21271        run_test_git(&source, &["commit", "-m", "seed"]);
21272        let profile = ManagedWorktreeProfile::new(
21273            WorktreeProfileId::new("default").unwrap(),
21274            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21275            &allocation,
21276            "gate4agent",
21277            "HEAD",
21278            ManagedWorktreeRetention::RemoveWhenReleased,
21279        )
21280        .unwrap();
21281        let workspace = WorkspaceConfig::new(
21282            WorkspaceId::new("primary").unwrap(),
21283            &source,
21284        )
21285        .unwrap()
21286        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21287        .with_managed_worktree_profile(profile.clone())
21288        .unwrap();
21289        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21290            materialization_test_shared_with_workspace(false, workspace);
21291        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21292        let source_text = source.to_string_lossy().into_owned();
21293        let target_text = target.to_string_lossy().into_owned();
21294        let created = create_git_worktree(
21295            &source_text,
21296            &target_text,
21297            "gate4agent/mw-raw-inventory",
21298            Some("HEAD"),
21299        )
21300        .await
21301        .unwrap();
21302        let lease = ManagedWorktreeLeaseRecord {
21303            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21304            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21305            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21306            profile_id: profile.profile_id().clone(),
21307            profile_revision: profile.revision().clone(),
21308            target_root: created.path.clone(),
21309            branch: "gate4agent/mw-raw-inventory".to_owned(),
21310            base_commit: created.head.clone(),
21311            expected_head: Some(created.head.clone()),
21312            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21313            state: ManagedWorktreeLeaseState::Ready,
21314            session_holders: Vec::new(),
21315            record_holders: Vec::new(),
21316            cleanup_failure: None,
21317            created_at_unix_ms: 1,
21318            updated_at_unix_ms: 1,
21319        };
21320        shared
21321            .workspaces
21322            .write()
21323            .unwrap_or_else(|poisoned| poisoned.into_inner())
21324            .insert(lease.workspace_id.clone(), created.path.clone());
21325        *shared
21326            .managed_worktrees
21327            .lock()
21328            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21329            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21330        let address = SessionAddress {
21331            workspace_id: lease.workspace_id.clone(),
21332            session: SessionKey {
21333                instance_id: AgentInstanceId(205),
21334                generation: SessionGeneration(1),
21335            },
21336        };
21337        let (overlay, materialization_guard) = shared
21338            .prepare_session_materialization(
21339                &address,
21340                &agent("claude"),
21341                SessionMode::Pty,
21342                Some(&receipt),
21343                None,
21344                None,
21345                Some(lease.lease_id.clone()),
21346            )
21347            .unwrap()
21348            .unwrap();
21349        let materialization_id = materialization_guard.id().unwrap().clone();
21350        let materialization_root = shared
21351            .materializations
21352            .lock()
21353            .unwrap_or_else(|poisoned| poisoned.into_inner())
21354            .get(&materialization_id)
21355            .unwrap()
21356            .root()
21357            .to_path_buf();
21358        let selection = shared
21359            .select_environment_profile(
21360                address.session.instance_id,
21361                &agent("claude"),
21362                SessionMode::Pty,
21363                Some(&receipt),
21364            )
21365            .unwrap()
21366            .unwrap();
21367        assert!(shared
21368            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21369            .unwrap()
21370            .is_none());
21371        let record_id = shared
21372            .bind_spawn_session_with_materialization(
21373                &address,
21374                agent("claude"),
21375                SessionMode::Pty,
21376                ProviderRuntimePolicy::raw_pty(),
21377                SpawnRecordPolicy::Always,
21378                Some(receipt.clone()),
21379                None,
21380                None,
21381                Some(materialization_id.clone()),
21382            )
21383            .unwrap()
21384            .unwrap();
21385        selection.retain();
21386        materialization_guard.retain();
21387        let record = shared.record(&record_id).unwrap();
21388        assert_eq!(record.state, ManagedSessionState::Live);
21389        assert!(record.provider_session.is_none());
21390        assert!(record.environment_profile.is_none());
21391        assert!(record.bundle.is_none());
21392        assert!(record.context_id.is_none());
21393        assert!(record.context.is_none());
21394        assert_eq!(record.active_session.as_ref(), Some(&address));
21395        assert_eq!(
21396            shared
21397                .materializations
21398                .lock()
21399                .unwrap_or_else(|poisoned| poisoned.into_inner())
21400                .get(&materialization_id)
21401                .unwrap()
21402                .owner(),
21403            &MaterializationOwner::Session {
21404                incarnation_id: shared.incarnation_id,
21405                instance_id: address.session.instance_id,
21406                generation: address.session.generation,
21407            },
21408        );
21409        {
21410            let mut bindings = shared
21411                .session_bindings
21412                .lock()
21413                .unwrap_or_else(|poisoned| poisoned.into_inner());
21414            bindings
21415                .get_mut(&address.session.instance_id)
21416                .unwrap()
21417                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21418        }
21419        let lease_snapshot = shared
21420            .managed_worktrees
21421            .lock()
21422            .unwrap_or_else(|poisoned| poisoned.into_inner())
21423            .bind_session(
21424                &lease.lease_id,
21425                ManagedWorktreeSessionHolder {
21426                    incarnation_id: shared.incarnation_id,
21427                    instance_id: address.session.instance_id,
21428                    generation: address.session.generation,
21429                },
21430                None,
21431                unix_time_ms(),
21432            )
21433            .unwrap();
21434        assert_eq!(lease_snapshot.active_session_count, 1);
21435        assert_eq!(lease_snapshot.managed_record_count, 0);
21436        shared.persist_state().unwrap();
21437        assert!(shared
21438            .managed_worktrees
21439            .lock()
21440            .unwrap_or_else(|poisoned| poisoned.into_inner())
21441            .get(&lease.lease_id)
21442            .unwrap()
21443            .record_holders
21444            .is_empty());
21445        shared
21446            .handle
21447            .dispatch(shared.prepare_command(ControlCommand::Register {
21448                instance_id: address.session.instance_id,
21449                agent_id: agent("claude"),
21450                transport: TransportKind::Pty,
21451            }))
21452            .unwrap();
21453        runtime.tick().await;
21454        shared.reconcile_managed_record(
21455            &record_id,
21456            &address,
21457            &ControlEvent {
21458                sequence: 1,
21459                command_id: None,
21460                instance_id: address.session.instance_id,
21461                generation: address.session.generation,
21462                event: ControlEventKind::Removed,
21463            },
21464            false,
21465            false,
21466        );
21467        let remove = shared.remove_session(&address);
21468        let drive_runtime = async {
21469            runtime.tick().await;
21470        };
21471        let (removed, ()) = tokio::join!(remove, drive_runtime);
21472        removed.unwrap();
21473
21474        assert!(!materialization_root.exists());
21475        assert!(!Path::new(&created.path).exists());
21476        assert_eq!(
21477            shared
21478                .managed_worktrees
21479                .lock()
21480                .unwrap_or_else(|poisoned| poisoned.into_inner())
21481                .get(&lease.lease_id)
21482                .unwrap()
21483                .state,
21484            ManagedWorktreeLeaseState::Removed,
21485        );
21486        let renamed = shared
21487            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21488            .unwrap();
21489        assert_eq!(renamed.record_id, record_id);
21490        assert_eq!(renamed.display_name, "raw managed inventory");
21491        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21492        assert!(renamed.active_session.is_none());
21493
21494        drop(shared);
21495        drop(runtime);
21496        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21497        std::fs::remove_dir_all(git_root).unwrap();
21498    }
21499
21500    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21501        let root = std::env::current_dir().unwrap()
21502            .join("managed-test-target")
21503            .to_string_lossy()
21504            .into_owned();
21505        let lease = ManagedWorktreeLeaseRecord {
21506            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21507            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21508            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21509            profile_id: WorktreeProfileId::new("default").unwrap(),
21510            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21511            target_root: root.clone(),
21512            branch: "gate4agent/mw-test".to_owned(),
21513            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21514            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21515            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21516            state: ManagedWorktreeLeaseState::Ready,
21517            session_holders: Vec::new(),
21518            record_holders: Vec::new(),
21519            cleanup_failure: None,
21520            created_at_unix_ms: 1,
21521            updated_at_unix_ms: 1,
21522        };
21523        shared.workspaces.write()
21524            .unwrap_or_else(|poisoned| poisoned.into_inner())
21525            .insert(lease.workspace_id.clone(), root);
21526        *shared.managed_worktrees.lock()
21527            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21528            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21529        lease
21530    }
21531
21532    #[cfg(feature = "fixture")]
21533    #[test]
21534    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21535        let mut shared = terminal_test_shared();
21536        shared.fixture_semantic_hook_policy = true;
21537        let lease = install_managed_lease(&shared);
21538        let address = SessionAddress {
21539            workspace_id: lease.workspace_id.clone(),
21540            session: SessionKey {
21541                instance_id: AgentInstanceId(206),
21542                generation: SessionGeneration(1),
21543            },
21544        };
21545        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21546        let effective_runtime_policy =
21547            shared.effective_spawn_runtime_policy(pre_admission_policy);
21548        assert_ne!(effective_runtime_policy, pre_admission_policy);
21549        assert!(effective_runtime_policy.provider_session_identity);
21550
21551        let record_id = shared
21552            .bind_spawn_session(
21553                &address,
21554                agent("claude"),
21555                SessionMode::Pty,
21556                effective_runtime_policy,
21557                SpawnRecordPolicy::Always,
21558                None,
21559            )
21560            .unwrap()
21561            .unwrap();
21562        assert_eq!(
21563            shared.record(&record_id).unwrap().state,
21564            ManagedSessionState::IdentityPending,
21565        );
21566        let (record_holder, raw_inventory_record_id) = {
21567            let mut bindings = shared
21568                .session_bindings
21569                .lock()
21570                .unwrap_or_else(|poisoned| poisoned.into_inner());
21571            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21572            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21573            managed_spawn_record_ownership(binding, effective_runtime_policy)
21574        };
21575        assert_eq!(record_holder.as_ref(), Some(&record_id));
21576        assert!(raw_inventory_record_id.is_none());
21577        let snapshot = shared
21578            .managed_worktrees
21579            .lock()
21580            .unwrap_or_else(|poisoned| poisoned.into_inner())
21581            .bind_session(
21582                &lease.lease_id,
21583                ManagedWorktreeSessionHolder {
21584                    incarnation_id: shared.incarnation_id,
21585                    instance_id: address.session.instance_id,
21586                    generation: address.session.generation,
21587                },
21588                record_holder,
21589                unix_time_ms(),
21590            )
21591            .unwrap();
21592        assert_eq!(snapshot.managed_record_count, 1);
21593        assert_eq!(
21594            shared
21595                .managed_worktrees
21596                .lock()
21597                .unwrap_or_else(|poisoned| poisoned.into_inner())
21598                .get(&lease.lease_id)
21599                .unwrap()
21600                .record_holders,
21601            vec![record_id.clone()],
21602        );
21603
21604        let identity = ProviderSessionIdentity {
21605            key: ProviderSessionKey::SessionId,
21606            id: "monitoring-fixture-managed-session".to_owned(),
21607            transcript_path: None,
21608        };
21609        shared.publish_control(ControlEvent {
21610            sequence: 1,
21611            command_id: None,
21612            instance_id: address.session.instance_id,
21613            generation: address.session.generation,
21614            event: ControlEventKind::ProviderEvent {
21615                sequence: 1,
21616                source: gate4agent_types::ProviderSource {
21617                    family: AdapterFamily::Hook,
21618                    binding: AdapterBinding::new(
21619                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21620                        "fixture/v1",
21621                        gate4agent_types::AdapterVerification::SyntheticFixture,
21622                    )
21623                    .unwrap(),
21624                },
21625                source_sequence: 1,
21626                event: ProviderEvent::SessionIdentityObserved {
21627                    identity: identity.clone(),
21628                },
21629            },
21630        });
21631
21632        let live = shared.record(&record_id).unwrap();
21633        assert_eq!(live.state, ManagedSessionState::Live);
21634        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21635        assert_eq!(live.active_session.as_ref(), Some(&address));
21636        assert_eq!(
21637            shared
21638                .managed_worktrees
21639                .lock()
21640                .unwrap_or_else(|poisoned| poisoned.into_inner())
21641                .get(&lease.lease_id)
21642                .unwrap()
21643                .record_holders,
21644            vec![record_id],
21645        );
21646    }
21647
21648    fn managed_v2_test_shared(
21649        allocation_root: &Path,
21650        state_path: Option<PathBuf>,
21651        profile_revision: &str,
21652    ) -> NodeShared {
21653        let profile = ManagedWorktreeProfile::new(
21654            WorktreeProfileId::new("default").unwrap(),
21655            WorktreeProfileRevision::new(profile_revision).unwrap(),
21656            allocation_root,
21657            "gate4agent",
21658            "HEAD",
21659            ManagedWorktreeRetention::RemoveWhenReleased,
21660        )
21661        .unwrap();
21662        let workspace = WorkspaceConfig::new(
21663            WorkspaceId::new("primary").unwrap(),
21664            std::env::current_dir().unwrap(),
21665        )
21666        .unwrap()
21667        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21668        .with_managed_worktree_profile(profile)
21669        .unwrap();
21670        let catalog = active_registry().unwrap();
21671        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21672        let mut shared = NodeShared::new(
21673            handle,
21674            "fixture-token".to_owned(),
21675            NodeId::new("node-terminal-test").unwrap(),
21676            vec![workspace],
21677            vec![agent("claude")],
21678        );
21679        shared.state_path = state_path;
21680        shared
21681    }
21682
21683    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21684        let mut spawn_spec = spawn_spec_fixture();
21685        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21686        ManagedWorktreeSpawnRequestV2 {
21687            spawn_spec,
21688            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21689            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21690        }
21691    }
21692
21693    #[test]
21694    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21695        let shared = terminal_test_shared();
21696        let mut lease = install_managed_lease(&shared);
21697        let session = SessionAddress {
21698            workspace_id: lease.workspace_id.clone(),
21699            session: SessionKey {
21700                instance_id: AgentInstanceId(73),
21701                generation: SessionGeneration(4),
21702            },
21703        };
21704        lease.state = ManagedWorktreeLeaseState::InUse;
21705        lease.session_holders.push(ManagedWorktreeSessionHolder {
21706            incarnation_id: shared.incarnation_id,
21707            instance_id: session.session.instance_id,
21708            generation: session.session.generation,
21709        });
21710        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21711        assert!(resolved.target.worktree_id.is_none());
21712
21713        let receipt = managed_spawn_receipt(
21714            &resolved,
21715            shared.incarnation_id,
21716            session.clone(),
21717            lease.snapshot(),
21718            None,
21719            None,
21720            None,
21721        );
21722        assert!(resolved.target.worktree_id.is_none());
21723        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21724        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21725        assert_eq!(receipt.spawn.session, session);
21726        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21727        let encoded = serde_json::to_vec(&receipt).unwrap();
21728        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21729        assert_eq!(decoded, receipt);
21730    }
21731
21732    #[tokio::test]
21733    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21734        let root = temporary_workspace_root("managed-v2-revision");
21735        let allocation = root.join("allocation");
21736        std::fs::create_dir_all(&allocation).unwrap();
21737        let shared = managed_v2_test_shared(
21738            &allocation,
21739            Some(root.join("node-state.json")),
21740            "v1",
21741        );
21742        let mut request = managed_v2_request("managed-v2-revision");
21743        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21744
21745        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21746
21747        assert_eq!(
21748            failure.code,
21749            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21750        );
21751        assert!(shared
21752            .managed_worktrees
21753            .lock()
21754            .unwrap_or_else(|poisoned| poisoned.into_inner())
21755            .records()
21756            .is_empty());
21757        assert!(shared
21758            .managed_spawn_replays
21759            .lock()
21760            .unwrap_or_else(|poisoned| poisoned.into_inner())
21761            .is_empty());
21762        std::fs::remove_dir_all(root).unwrap();
21763    }
21764
21765    #[test]
21766    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21767        let root = temporary_workspace_root("managed-v2-concurrent");
21768        let allocation = root.join("allocation");
21769        std::fs::create_dir_all(&allocation).unwrap();
21770        let shared = Arc::new(managed_v2_test_shared(
21771            &allocation,
21772            Some(root.join("node-state.json")),
21773            "v1",
21774        ));
21775        let request = managed_v2_request("managed-v2-concurrent");
21776        let barrier = Arc::new(std::sync::Barrier::new(3));
21777        let mut joins = Vec::new();
21778        for _ in 0..2 {
21779            let shared = Arc::clone(&shared);
21780            let request = request.clone();
21781            let barrier = Arc::clone(&barrier);
21782            joins.push(std::thread::spawn(move || {
21783                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21784                barrier.wait();
21785                shared.acquire_durable_managed_spawn_v2(&request, digest)
21786            }));
21787        }
21788        barrier.wait();
21789        let results = joins
21790            .into_iter()
21791            .map(|join| join.join().unwrap())
21792            .collect::<Vec<_>>();
21793        assert_eq!(
21794            results
21795                .iter()
21796                .filter(|result| {
21797                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21798                })
21799                .count(),
21800            1,
21801        );
21802        assert_eq!(
21803            results
21804                .iter()
21805                .filter(|result| {
21806                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21807                })
21808                .count(),
21809            1,
21810        );
21811        assert_eq!(
21812            shared
21813                .managed_spawn_replays
21814                .lock()
21815                .unwrap_or_else(|poisoned| poisoned.into_inner())
21816                .len(),
21817            1,
21818        );
21819        assert!(shared
21820            .managed_worktrees
21821            .lock()
21822            .unwrap_or_else(|poisoned| poisoned.into_inner())
21823            .records()
21824            .is_empty());
21825        drop(shared);
21826        std::fs::remove_dir_all(root).unwrap();
21827    }
21828
21829    #[tokio::test]
21830    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21831        let root = temporary_workspace_root("managed-v2-linked-pending");
21832        let allocation = root.join("allocation");
21833        std::fs::create_dir_all(&allocation).unwrap();
21834        let state_path = root.join("node-state.json");
21835        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21836        let lease = install_managed_lease(&shared);
21837        let request = managed_v2_request("managed-v2-linked-pending");
21838        assert!(matches!(
21839            shared
21840                .acquire_durable_managed_spawn_v2(
21841                    &request,
21842                    managed_spawn_request_digest_v2(&request).unwrap(),
21843                )
21844                .unwrap(),
21845            DurableManagedSpawnReplayDecision::Reserved,
21846        ));
21847        {
21848            let _transaction = shared
21849                .state_transaction
21850                .lock()
21851                .unwrap_or_else(|poisoned| poisoned.into_inner());
21852            shared
21853                .link_durable_managed_spawn_v2_lease_locked(
21854                    &request.spawn_spec.idempotency_key,
21855                    &lease.lease_id,
21856                )
21857                .unwrap();
21858            shared.persist_state_locked().unwrap();
21859        }
21860        drop(shared);
21861
21862        let mut loaded = session_registry::load(
21863            Some(&state_path),
21864            &NodeId::new("node-terminal-test").unwrap(),
21865        )
21866        .unwrap();
21867        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21868        *reopened
21869            .managed_worktrees
21870            .lock()
21871            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21872            ManagedWorktreeRegistry::from_records(
21873                std::mem::take(&mut loaded.managed_worktrees),
21874                std::mem::take(&mut loaded.managed_worktree_tombstones),
21875            )
21876            .unwrap();
21877        *reopened
21878            .managed_spawn_replays
21879            .lock()
21880            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21881            .managed_spawn_replays
21882            .into_iter()
21883            .map(|record| (record.idempotency_key.clone(), record))
21884            .collect();
21885        let lease_count = reopened
21886            .managed_worktrees
21887            .lock()
21888            .unwrap_or_else(|poisoned| poisoned.into_inner())
21889            .records()
21890            .len();
21891
21892        let failure = reopened
21893            .spawn_managed_worktree_v2(request)
21894            .await
21895            .unwrap_err();
21896
21897        assert_eq!(
21898            failure.code,
21899            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21900        );
21901        assert_eq!(
21902            reopened
21903                .managed_worktrees
21904                .lock()
21905                .unwrap_or_else(|poisoned| poisoned.into_inner())
21906                .records()
21907                .len(),
21908            lease_count,
21909        );
21910        std::fs::remove_dir_all(root).unwrap();
21911    }
21912
21913    #[tokio::test]
21914    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21915        let root = temporary_workspace_root("managed-v2-reopen");
21916        let allocation = root.join("allocation");
21917        std::fs::create_dir_all(&allocation).unwrap();
21918        let state_path = root.join("node-state.json");
21919        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21920        let lease = install_managed_lease(&shared);
21921        let request = managed_v2_request("managed-v2-reopen");
21922        let session = SessionAddress {
21923            workspace_id: lease.workspace_id.clone(),
21924            session: SessionKey {
21925                instance_id: AgentInstanceId(91),
21926                generation: SessionGeneration(1),
21927            },
21928        };
21929        let snapshot = {
21930            let mut registry = shared
21931                .managed_worktrees
21932                .lock()
21933                .unwrap_or_else(|poisoned| poisoned.into_inner());
21934            let current = registry.get_mut(&lease.lease_id).unwrap();
21935            current.state = ManagedWorktreeLeaseState::InUse;
21936            current.session_holders.push(ManagedWorktreeSessionHolder {
21937                incarnation_id: shared.incarnation_id,
21938                instance_id: session.session.instance_id,
21939                generation: session.session.generation,
21940            });
21941            current.snapshot()
21942        };
21943        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21944        let receipt = managed_spawn_receipt(
21945            &resolved,
21946            shared.incarnation_id,
21947            session,
21948            snapshot,
21949            None,
21950            None,
21951            None,
21952        );
21953        assert!(matches!(
21954            shared
21955                .acquire_durable_managed_spawn_v2(
21956                    &request,
21957                    managed_spawn_request_digest_v2(&request).unwrap(),
21958                )
21959                .unwrap(),
21960            DurableManagedSpawnReplayDecision::Reserved,
21961        ));
21962        {
21963            let _transaction = shared
21964                .state_transaction
21965                .lock()
21966                .unwrap_or_else(|poisoned| poisoned.into_inner());
21967            shared
21968                .link_durable_managed_spawn_v2_lease_locked(
21969                    &request.spawn_spec.idempotency_key,
21970                    &lease.lease_id,
21971                )
21972                .unwrap();
21973            shared.persist_state_locked().unwrap();
21974        }
21975        shared
21976            .commit_durable_managed_spawn_v2(&request, receipt.clone())
21977            .unwrap();
21978        let legacy_same_key = ManagedWorktreeSpawnRequest {
21979            spawn_spec: request.spawn_spec.clone(),
21980            worktree_profile_id: request.worktree_profile_id.clone(),
21981        };
21982        shared.remember_managed_spawn_attempt(
21983            Some(&request.spawn_spec.idempotency_key),
21984            legacy_same_key.clone(),
21985            Ok(receipt.clone()),
21986            Instant::now(),
21987        );
21988        assert!(shared
21989            .replay_managed_spawn(&legacy_same_key, Instant::now())
21990            .unwrap()
21991            .is_none());
21992        drop(shared);
21993
21994        let mut loaded = session_registry::load(
21995            Some(&state_path),
21996            &NodeId::new("node-terminal-test").unwrap(),
21997        )
21998        .unwrap();
21999        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22000        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22001        *reopened
22002            .managed_worktrees
22003            .lock()
22004            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22005            ManagedWorktreeRegistry::from_records(
22006                std::mem::take(&mut loaded.managed_worktrees),
22007                std::mem::take(&mut loaded.managed_worktree_tombstones),
22008            )
22009            .unwrap();
22010        *reopened
22011            .managed_spawn_replays
22012            .lock()
22013            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22014            .managed_spawn_replays
22015            .into_iter()
22016            .map(|record| (record.idempotency_key.clone(), record))
22017            .collect();
22018        let lease_count_before = reopened
22019            .managed_worktrees
22020            .lock()
22021            .unwrap_or_else(|poisoned| poisoned.into_inner())
22022            .records()
22023            .len();
22024
22025        assert_eq!(
22026            reopened
22027                .spawn_managed_worktree_v2(request.clone())
22028                .await
22029                .unwrap(),
22030            receipt,
22031        );
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 changed = request;
22042        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22043        let failure = reopened
22044            .spawn_managed_worktree_v2(changed)
22045            .await
22046            .unwrap_err();
22047        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22048        assert_eq!(
22049            reopened
22050                .managed_worktrees
22051                .lock()
22052                .unwrap_or_else(|poisoned| poisoned.into_inner())
22053                .records()
22054                .len(),
22055            lease_count_before,
22056        );
22057        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22058        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22059        let failure = reopened
22060            .spawn_managed_worktree_v2(new_key)
22061            .await
22062            .unwrap_err();
22063        assert_eq!(
22064            failure.code,
22065            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22066        );
22067        assert_eq!(
22068            reopened
22069                .managed_worktrees
22070                .lock()
22071                .unwrap_or_else(|poisoned| poisoned.into_inner())
22072                .records()
22073                .len(),
22074            lease_count_before,
22075        );
22076        assert_eq!(
22077            reopened
22078                .managed_spawn_replays
22079                .lock()
22080                .unwrap_or_else(|poisoned| poisoned.into_inner())
22081                .len(),
22082            1,
22083        );
22084        std::fs::remove_dir_all(root).unwrap();
22085    }
22086
22087    #[test]
22088    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22089        let shared = terminal_test_shared();
22090        let request = ManagedWorktreeSpawnRequest {
22091            spawn_spec: spawn_spec_fixture(),
22092            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22093        };
22094        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22095        shared.remember_managed_spawn(
22096            request.clone(),
22097            Err(expected.clone()),
22098            Instant::now(),
22099        );
22100
22101        assert_eq!(
22102            shared
22103                .replay_managed_spawn(&request, Instant::now())
22104                .unwrap()
22105                .unwrap(),
22106            Err(expected),
22107        );
22108        assert!(shared
22109            .managed_spawn_replays
22110            .lock()
22111            .unwrap_or_else(|poisoned| poisoned.into_inner())
22112            .is_empty());
22113    }
22114
22115    #[tokio::test]
22116    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22117        let shared = terminal_test_shared();
22118        let lease = install_managed_lease(&shared);
22119        let raw = shared.spawn_session(
22120            lease.workspace_id.clone(),
22121            agent("claude"),
22122            SessionMode::Pty,
22123            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22124            None,
22125        ).await.unwrap_err();
22126        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22127        let mut spec = spawn_spec_fixture();
22128        spec.target.workspace_id = lease.workspace_id.clone();
22129        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22130        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22131        let mut selected = spawn_spec_fixture();
22132        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22133        selected.target.worktree_id = Some(lease.workspace_id);
22134        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22135        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22136        assert!(shared.handle.snapshot().sessions.is_empty());
22137    }
22138
22139    #[test]
22140    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22141        let shared = terminal_test_shared();
22142        let lease = install_managed_lease(&shared);
22143        let snapshot = snapshot_for_wire(
22144            &shared, true, true, true, false, true, true, false, false, true,
22145        );
22146        assert!(snapshot.managed_worktrees.is_empty());
22147        assert!(snapshot.workspaces.iter().all(|workspace| {
22148            workspace.managed_worktree_profiles.is_none()
22149        }));
22150        let mut reply = ResponseEnvelope {
22151            request_id: 1,
22152            result: Ok(NodeResponse::Resync {
22153                event_sequence: 1,
22154                oldest_available_sequence: 1,
22155                snapshot: shared.snapshot(),
22156                events: vec![NodeEventEnvelope {
22157                    sequence: 1,
22158                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22159                }],
22160            }),
22161        };
22162        project_response_without_managed_worktrees(&mut reply);
22163        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22164            panic!("projection changed the response kind");
22165        };
22166        assert!(snapshot.managed_worktrees.is_empty());
22167        assert!(events.is_empty());
22168        let mut git = git_snapshot_for_parser();
22169        git.branch = Some(lease.branch.clone());
22170        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22171            lease_id: lease.lease_id.clone(),
22172            source_workspace_id: lease.source_workspace_id.clone(),
22173            branch: lease.branch.clone(),
22174            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22175            active_session_count: 1,
22176            managed_record_count: 0,
22177        });
22178        let mut inspection_reply = ResponseEnvelope {
22179            request_id: 2,
22180            result: Ok(NodeResponse::WorkspaceInspected {
22181                inspection: WorkspaceInspection {
22182                    workspace_id: lease.workspace_id.clone(),
22183                    entries: Vec::new(),
22184                    tree_truncated: false,
22185                    git,
22186                    truncation: None,
22187                },
22188            }),
22189        };
22190        project_response_without_managed_worktrees(&mut inspection_reply);
22191        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22192            panic!("projection changed the inspection response kind");
22193        };
22194        assert!(inspection.git.managed_worktree.is_none());
22195        let failure = managed_git_worktree_failure(GitWorktreeError {
22196            kind: GitWorktreeErrorKind::Failed,
22197            message: r"git failed at C:\private\managed-target".to_owned(),
22198        });
22199        assert!(!failure.message.contains("private"));
22200        assert!(!failure.message.contains("managed-target"));
22201    }
22202
22203    #[tokio::test]
22204    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22205        let root = temporary_workspace_root("managed-restart");
22206        let source = root.join("source");
22207        let allocation = root.join("allocation");
22208        std::fs::create_dir_all(&source).unwrap();
22209        std::fs::create_dir_all(&allocation).unwrap();
22210        run_test_git(&source, &["init"]);
22211        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22212        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22213        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22214        run_test_git(&source, &["add", "seed.txt"]);
22215        run_test_git(&source, &["commit", "-m", "seed"]);
22216        let profile = ManagedWorktreeProfile::new(
22217            WorktreeProfileId::new("default").unwrap(),
22218            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22219            &allocation,
22220            "gate4agent",
22221            "HEAD",
22222            ManagedWorktreeRetention::Retain,
22223        ).unwrap();
22224        let workspace = WorkspaceConfig::new(
22225            WorkspaceId::new("primary").unwrap(),
22226            &source,
22227        ).unwrap()
22228            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22229            .with_managed_worktree_profile(profile.clone()).unwrap();
22230        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22231        let source_text = workspace.canonical_root().to_owned();
22232        let target_text = target.to_string_lossy().into_owned();
22233        let created = create_git_worktree(
22234            &source_text,
22235            &target_text,
22236            "gate4agent/mw-restart",
22237            Some("HEAD"),
22238        ).await.unwrap();
22239        let lease = ManagedWorktreeLeaseRecord {
22240            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22241            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22242            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22243            profile_id: profile.profile_id().clone(),
22244            profile_revision: profile.revision().clone(),
22245            target_root: created.path.clone(),
22246            branch: "gate4agent/mw-restart".to_owned(),
22247            base_commit: created.head.clone(),
22248            expected_head: None,
22249            retention: ManagedWorktreeRetention::Retain,
22250            state: ManagedWorktreeLeaseState::Allocating,
22251            session_holders: Vec::new(),
22252            record_holders: Vec::new(),
22253            cleanup_failure: None,
22254            created_at_unix_ms: 1,
22255            updated_at_unix_ms: 1,
22256        };
22257        let catalog = active_registry().unwrap();
22258        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22259        let shared = NodeShared::new_with_incarnation(
22260            handle,
22261            "fixture-token".to_owned(),
22262            NodeId::new("node-managed-restart").unwrap(),
22263            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22264            vec![workspace],
22265            vec![agent("claude")],
22266            ProviderRuntimeStatuses::default(),
22267            None,
22268            Vec::new(),
22269            Vec::new(),
22270            SpawnProfileRegistry::default(),
22271            None,
22272            None,
22273            None,
22274            None,
22275            Vec::new(),
22276            vec![lease.clone()],
22277            Vec::new(),
22278            Vec::new(),
22279            None,
22280            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22281        );
22282        shared.reconcile_managed_worktrees().await;
22283        let retained = shared.managed_worktrees.lock()
22284            .unwrap_or_else(|poisoned| poisoned.into_inner())
22285            .get(&lease.lease_id).unwrap().clone();
22286        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22287        assert!(shared.workspaces.read()
22288            .unwrap_or_else(|poisoned| poisoned.into_inner())
22289            .contains_key(&lease.workspace_id));
22290        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22291        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22292        assert!(!Path::new(&created.path).exists());
22293        std::fs::remove_dir_all(root).unwrap();
22294    }
22295
22296    fn run_test_git(root: &Path, arguments: &[&str]) {
22297        let output = std::process::Command::new("git")
22298            .arg("-C").arg(root).args(arguments).output().unwrap();
22299        assert!(
22300            output.status.success(),
22301            "git {:?} failed: {}",
22302            arguments,
22303            String::from_utf8_lossy(&output.stderr),
22304        );
22305    }
22306
22307    #[tokio::test]
22308    async fn spawn_spec_validation_fails_before_session_mutation() {
22309        let shared = terminal_test_shared();
22310        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22311            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22312        }));
22313        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22314            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22315        }));
22316        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22317            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22318        }));
22319
22320        let mut target_mismatch = spawn_spec_fixture();
22321        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22322        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22323        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22324
22325        let mut unknown_profile = spawn_spec_fixture();
22326        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22327        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22328        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22329        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22330
22331        let mut revision_mismatch = spawn_spec_fixture();
22332        revision_mismatch.idempotency_key =
22333            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22334        revision_mismatch.expected_profile_revision =
22335            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22336        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22337        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22338
22339        let mut unsupported_materializer = spawn_spec_fixture();
22340        unsupported_materializer.idempotency_key =
22341            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22342        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22343            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22344        };
22345        let failure = shared
22346            .spawn_from_spec(unsupported_materializer)
22347            .await
22348            .unwrap_err();
22349        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22350
22351        let failure = shared
22352            .spawn_session_with_deadline(
22353                WorkspaceId::new("primary").unwrap(),
22354                agent("claude"),
22355                SessionMode::Pty,
22356                gate4agent_types::TerminalSize {
22357                    rows: 24,
22358                    columns: 80,
22359                },
22360                None,
22361                None,
22362                None,
22363                None,
22364                None,
22365                None,
22366                SpawnRecordPolicy::ProviderIdentityOnly,
22367                Some(Instant::now()),
22368                &[],
22369                None,
22370                ApprovalLevel::default(),
22371                None,
22372                None,
22373            )
22374            .await
22375            .unwrap_err();
22376        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22377        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22378        assert!(shared.handle.snapshot().sessions.is_empty());
22379    }
22380
22381    #[test]
22382    fn worktree_selection_requires_its_separate_negotiated_capability() {
22383        let mut spec = spawn_spec_fixture();
22384        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22385        let request = NodeRequest::SpawnSpec { spec };
22386        let spawn_spec_capability =
22387            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22388        assert!(request_uses_unnegotiated_capability(
22389            &request,
22390            &[spawn_spec_capability.clone()],
22391        ));
22392        let worktree_capability =
22393            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22394        assert!(request_uses_unnegotiated_capability(
22395            &request,
22396            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22397        ));
22398        let profile_revision_capability =
22399            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_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            ],
22407        ));
22408        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22409        // `Inherit`, not `Clear` — the profile's own defaults might still
22410        // materialize a bundle or a context pack, so both capabilities stay
22411        // conservatively required until negotiated (see
22412        // `requires_session_bundle_materialization_capability` /
22413        // `requires_history_context_pack_capability`).
22414        let bundle_materialization_capability =
22415            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22416        assert!(request_uses_unnegotiated_capability(
22417            &request,
22418            &[
22419                spawn_spec_capability.clone(),
22420                worktree_capability.clone(),
22421                profile_revision_capability.clone(),
22422                bundle_materialization_capability.clone(),
22423            ],
22424        ));
22425        let history_context_pack_capability =
22426            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22427        assert!(!request_uses_unnegotiated_capability(
22428            &request,
22429            &[
22430                spawn_spec_capability,
22431                worktree_capability,
22432                profile_revision_capability,
22433                bundle_materialization_capability,
22434                history_context_pack_capability,
22435            ],
22436        ));
22437    }
22438
22439    #[tokio::test]
22440    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22441        let mut shared = terminal_test_shared();
22442        let spec = spawn_spec_fixture();
22443        let receipt = shared
22444            .resolve_spawn_spec(&spec)
22445            .unwrap()
22446            .receipt(shared.incarnation_id, terminal_address(1));
22447        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22448
22449        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22450        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22451        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22452
22453        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22454
22455        let mut revision_conflicting = spec.clone();
22456        revision_conflicting.expected_profile_revision =
22457            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22458        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22459        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22460
22461        let mut stale_cache_miss = spec.clone();
22462        stale_cache_miss.idempotency_key =
22463            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22464        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22465        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22466
22467        let mut conflicting = spec;
22468        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22469        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22470        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22471        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22472        assert!(shared.handle.snapshot().sessions.is_empty());
22473    }
22474
22475    #[tokio::test]
22476    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22477        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22478        let mut spec = spawn_spec_fixture();
22479        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22480        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22481        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22482
22483        let failure = shared
22484            .create_worktree(
22485                WorkspaceId::new("primary").unwrap(),
22486                WorkspaceId::new("new-worktree").unwrap(),
22487                "not-an-absolute-path".to_owned(),
22488                "invalid branch".to_owned(),
22489                None,
22490            )
22491            .await
22492            .unwrap_err();
22493        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22494
22495        let failure = shared
22496            .remove_worktree(
22497                WorkspaceId::new("primary").unwrap(),
22498                "not-an-absolute-path".to_owned(),
22499            )
22500            .await
22501            .unwrap_err();
22502        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22503        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22504        assert!(shared.handle.snapshot().sessions.is_empty());
22505    }
22506
22507    #[tokio::test]
22508    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22509        let root = temporary_workspace_root("manual-worktree-compensation");
22510        let source = root.join("source");
22511        let target = root.join("target");
22512        let invalid_state_parent = root.join("state-parent-is-a-file");
22513        let invalid_state_path = invalid_state_parent.join("state.json");
22514        std::fs::create_dir_all(&source).unwrap();
22515        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22516        run_test_git(&source, &["init"]);
22517        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22518        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22519        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22520        run_test_git(&source, &["add", "seed.txt"]);
22521        run_test_git(&source, &["commit", "-m", "seed"]);
22522
22523        let catalog = active_registry().unwrap();
22524        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22525        let workspace = WorkspaceConfig::new(
22526            WorkspaceId::new("primary").unwrap(),
22527            &source,
22528        ).unwrap();
22529        let mut shared = NodeShared::new(
22530            handle,
22531            "fixture-token".to_owned(),
22532            NodeId::new("node-worktree-compensation").unwrap(),
22533            vec![workspace],
22534            vec![agent("claude")],
22535        );
22536        shared.state_path = Some(invalid_state_path);
22537
22538        let error = shared.create_worktree(
22539            WorkspaceId::new("primary").unwrap(),
22540            WorkspaceId::new("compensated").unwrap(),
22541            target.to_string_lossy().into_owned(),
22542            "gate4agent/compensated".to_owned(),
22543            Some("HEAD".to_owned()),
22544        ).await.unwrap_err();
22545
22546        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22547        assert!(!target.exists());
22548        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22549            .iter()
22550            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22551        std::fs::remove_dir_all(root).unwrap();
22552    }
22553
22554    #[test]
22555    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22556        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22557            NativeGitWorktreeSnapshot {
22558                path: path.to_owned(),
22559                head: "0123456789abcdef".to_owned(),
22560                branch: Some("refs/heads/test".to_owned()),
22561                is_bare,
22562                is_main,
22563                locked: false,
22564                lock_reason: None,
22565                prunable: false,
22566                prunable_reason: None,
22567                workspace_id: None,
22568            }
22569        }
22570
22571        let source = r"C:\repo";
22572        let selected = r"C:\repo-worktree";
22573        let listed = vec![
22574            worktree(source, true, false),
22575            worktree(selected, false, false),
22576        ];
22577        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22578
22579        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22580        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22581
22582        let bare = vec![
22583            worktree(source, true, false),
22584            worktree(selected, false, true),
22585        ];
22586        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22587        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22588
22589        let foreign_failure =
22590            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22591        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22592    }
22593
22594    fn terminal_frame(sequence: u64) -> TerminalFrame {
22595        TerminalFrame {
22596            sequence,
22597            size: gate4agent_types::TerminalSize {
22598                rows: 24,
22599                columns: 80,
22600            },
22601            cursor_row: 2,
22602            cursor_column: 3,
22603            contents: format!("frame-{sequence}"),
22604            formatted: vec![1, 2, 3],
22605            scrollback_formatted: Vec::new(),
22606            alternate_screen: false,
22607            mouse_protocol_enabled: false,
22608            mouse_protocol_encoding: Default::default(),
22609            produced_at_unix_ms: 0,
22610            screen_state: gate4agent_types::PtyScreenState::default(),
22611            bracketed_paste: None,
22612        }
22613    }
22614
22615    #[test]
22616    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22617        let shared = terminal_test_shared();
22618        let mut receiver = shared.terminal_event_tx.subscribe();
22619        let original = terminal_address(1);
22620
22621        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22622        let first = receiver.try_recv().unwrap();
22623        assert_eq!(first.len(), 1);
22624        assert!(matches!(
22625            &first[0].event,
22626            NodeEvent::TerminalFrame { address, frame }
22627                if address == &original && frame.sequence == 7
22628        ));
22629
22630        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22631        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22632        assert!(matches!(
22633            receiver.try_recv(),
22634            Err(broadcast::error::TryRecvError::Empty)
22635        ));
22636
22637        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22638        let advanced = receiver.try_recv().unwrap();
22639        assert_eq!(advanced[0].sequence, 2);
22640
22641        let resumed = terminal_address(2);
22642        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22643        let reset = receiver.try_recv().unwrap();
22644        assert_eq!(reset[0].sequence, 3);
22645        assert!(matches!(
22646            &reset[0].event,
22647            NodeEvent::TerminalFrame { address, frame }
22648                if address == &resumed && frame.sequence == 1
22649        ));
22650        shared.clear_terminal_frame_watermark(&resumed);
22651        assert!(!shared
22652            .terminal_frame_watermarks
22653            .lock()
22654            .unwrap_or_else(|poisoned| poisoned.into_inner())
22655            .contains_key(&resumed.session.instance_id));
22656    }
22657
22658    #[test]
22659    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22660        let shared = terminal_test_shared();
22661        let mut receiver = shared.terminal_event_tx.subscribe();
22662        let address = terminal_address(1);
22663        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22664
22665        for sequence in 1..=count {
22666            shared.publish_terminal_frame_candidates(vec![(
22667                address.clone(),
22668                terminal_frame(sequence),
22669            )]);
22670        }
22671
22672        let history = shared
22673            .history
22674            .lock()
22675            .unwrap_or_else(|poisoned| poisoned.into_inner());
22676        assert!(history.events.is_empty());
22677        assert_eq!(history.last_sequence, count);
22678        assert_eq!(history.replay_floor_sequence, 1);
22679        drop(history);
22680        let NodeResponse::Resync {
22681            event_sequence,
22682            oldest_available_sequence,
22683            events,
22684            ..
22685        } = shared.resync(0) else {
22686            unreachable!("resync helper returned another response")
22687        };
22688        assert_eq!(event_sequence, count);
22689        assert_eq!(oldest_available_sequence, 1);
22690        assert!(events.is_empty());
22691        assert!(matches!(
22692            resync_required_event(&shared).event,
22693            NodeEvent::ResyncRequired {
22694                oldest_available_sequence: 1,
22695            }
22696        ));
22697        assert!(matches!(
22698            receiver.try_recv(),
22699            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22700        ));
22701        let latest = receiver.try_recv().unwrap();
22702        assert_eq!(latest.len(), 1);
22703        assert_eq!(latest[0].sequence, count);
22704    }
22705
22706    #[test]
22707    fn real_durable_eviction_advances_authoritative_replay_floor() {
22708        let shared = terminal_test_shared();
22709        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22710            shared.publish(NodeEvent::ControllerChanged { controller: None });
22711        }
22712
22713        let NodeResponse::Resync {
22714            event_sequence,
22715            oldest_available_sequence,
22716            events,
22717            ..
22718        } = shared.resync(0) else {
22719            unreachable!("resync helper returned another response")
22720        };
22721        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22722        assert_eq!(oldest_available_sequence, 2);
22723        assert_eq!(events.first().map(|event| event.sequence), Some(2));
22724        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22725        assert!(matches!(
22726            resync_required_event(&shared).event,
22727            NodeEvent::ResyncRequired {
22728                oldest_available_sequence: 2,
22729            }
22730        ));
22731    }
22732
22733    #[test]
22734    fn legacy_terminal_frame_connection_has_no_live_subscription() {
22735        let shared = terminal_test_shared();
22736        let without_capability = terminal_event_subscription(&shared, false);
22737        let with_capability = terminal_event_subscription(&shared, true);
22738        assert!(without_capability.is_none());
22739        assert!(with_capability.is_some());
22740        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22741            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22742        }));
22743    }
22744
22745    #[test]
22746    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22747        let mut pending = Vec::new();
22748        let mut event_sequence = 0_u64;
22749        for frame_sequence in 1..=4 {
22750            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22751                event_sequence += 1;
22752                let mut address = terminal_address(1);
22753                address.session.instance_id = AgentInstanceId(instance);
22754                queue_connection_event(
22755                    &mut pending,
22756                    NodeEventEnvelope {
22757                        sequence: event_sequence,
22758                        event: NodeEvent::TerminalFrame {
22759                            address,
22760                            frame: terminal_frame(frame_sequence),
22761                        },
22762                    },
22763                    0,
22764                );
22765            }
22766        }
22767        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22768        pending.sort_unstable_by_key(|event| event.sequence);
22769
22770        let mut uninterrupted = pending.clone();
22771        let first_burst = connection_event_burst_len(uninterrupted.len());
22772        uninterrupted.drain(..first_burst);
22773        let second_burst = connection_event_burst_len(uninterrupted.len());
22774        uninterrupted.drain(..second_burst);
22775        assert!(uninterrupted.is_empty());
22776
22777        let (frame_tx, mut frame_rx) =
22778            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22779        frame_tx
22780            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22781                request_id: 77,
22782                request: NodeRequest::Snapshot,
22783            })))
22784            .unwrap();
22785        let burst_len = connection_event_burst_len(pending.len());
22786        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22787        pending.drain(..burst_len);
22788        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22789        assert!(matches!(
22790            frame_rx.try_recv(),
22791            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22792                request_id: 77,
22793                request: NodeRequest::Snapshot,
22794            })))
22795        ));
22796    }
22797
22798    #[test]
22799    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22800        let mut pending = Vec::new();
22801        for sequence in [7_u64, 8] {
22802            let mut address = terminal_address(1);
22803            address.session.instance_id = AgentInstanceId(sequence);
22804            queue_connection_event(
22805                &mut pending,
22806                NodeEventEnvelope {
22807                    sequence,
22808                    event: NodeEvent::TerminalFrame {
22809                        address,
22810                        frame: terminal_frame(sequence),
22811                    },
22812                },
22813                0,
22814            );
22815        }
22816        let mut discard_through = 0;
22817        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22818        assert!(pending.is_empty());
22819
22820        let replayed = NodeEventEnvelope {
22821            sequence: 8,
22822            event: NodeEvent::TerminalFrame {
22823                address: terminal_address(1),
22824                frame: terminal_frame(8),
22825            },
22826        };
22827        let advanced = NodeEventEnvelope {
22828            sequence: 9,
22829            event: NodeEvent::TerminalFrame {
22830                address: terminal_address(2),
22831                frame: terminal_frame(1),
22832            },
22833        };
22834        queue_connection_event_batch(
22835            &mut pending,
22836            &[replayed, advanced.clone()],
22837            discard_through,
22838        );
22839        assert_eq!(pending, vec![advanced]);
22840    }
22841
22842    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22843        ManagedSessionRecord {
22844            record_id: SessionRecordId::new(record_id).unwrap(),
22845            display_name: format!("{provider} record"),
22846            provider: agent(provider),
22847            mode: SessionMode::Pty,
22848            state: ManagedSessionState::Unavailable,
22849            workspace_id: WorkspaceId::new("primary").unwrap(),
22850            canonical_root: opaque_windows_path(
22851                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22852            ),
22853            provider_session: None,
22854            active_session: None,
22855            environment_profile: None,
22856            bundle: None,
22857            context_id: None,
22858            context: None,
22859            exported_context: None,
22860            task_binding: None,
22861            created_at_unix_ms: 1,
22862            updated_at_unix_ms: 1,
22863            last_error: None,
22864        }
22865    }
22866
22867    fn retention_test_record(
22868        workspace_id: &str,
22869        record_id: &str,
22870        state: ManagedSessionState,
22871        updated_at_unix_ms: u64,
22872    ) -> ManagedSessionRecord {
22873        ManagedSessionRecord {
22874            record_id: SessionRecordId::new(record_id).unwrap(),
22875            display_name: format!("{record_id} display"),
22876            provider: agent("claude"),
22877            mode: SessionMode::Pty,
22878            state,
22879            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22880            canonical_root: opaque_windows_path(
22881                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22882            ),
22883            provider_session: None,
22884            active_session: None,
22885            environment_profile: None,
22886            bundle: None,
22887            context_id: None,
22888            context: None,
22889            exported_context: None,
22890            task_binding: None,
22891            created_at_unix_ms: updated_at_unix_ms,
22892            updated_at_unix_ms,
22893            last_error: None,
22894        }
22895    }
22896
22897    /// Two workspaces of three `Unavailable` records each, staggered by
22898    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22899    /// `workspace-a` (all as old as that workspace's oldest real record --
22900    /// if the sweep's state gate ever leaked, these would be the first
22901    /// thing it wrongly retired). Ages, oldest to newest:
22902    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
22903    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
22904    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22905        vec![
22906            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22907            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22908            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22909            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22910            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22911            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22912            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22913            retention_test_record(
22914                "workspace-a",
22915                "decoy-pending",
22916                ManagedSessionState::IdentityPending,
22917                now - 500_000,
22918            ),
22919            retention_test_record(
22920                "workspace-a",
22921                "decoy-dormant",
22922                ManagedSessionState::Dormant,
22923                now - 500_000,
22924            ),
22925        ]
22926    }
22927
22928    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22929        let root = temporary_workspace_root("session-record-retention");
22930        std::fs::create_dir_all(&root).unwrap();
22931        let catalog = active_registry().unwrap();
22932        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22933        let workspace_a =
22934            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22935        let workspace_b =
22936            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22937        let mut shared = NodeShared::new(
22938            handle,
22939            "fixture-token".to_owned(),
22940            NodeId::new("node-retention-test").unwrap(),
22941            vec![workspace_a, workspace_b],
22942            vec![agent("claude")],
22943        );
22944        shared.state_path = Some(root.join("node-state-v10.json"));
22945        let now = unix_time_ms();
22946        for record in retention_fixture_records(now) {
22947            shared.insert_record(record).unwrap();
22948        }
22949        shared
22950            .persist_state_locked()
22951            .expect("fixture-only, empty-history persist against a fresh temp state path");
22952        (shared, runtime, root, now)
22953    }
22954
22955    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22956        shared
22957            .history
22958            .lock()
22959            .unwrap_or_else(|poisoned| poisoned.into_inner())
22960            .events
22961            .iter()
22962            .filter_map(|envelope| match &envelope.event {
22963                NodeEvent::SessionRecordRemoved { record_id } => {
22964                    Some(record_id.as_str().to_owned())
22965                }
22966                _ => None,
22967            })
22968            .collect()
22969    }
22970
22971    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22972        shared
22973            .session_records
22974            .lock()
22975            .unwrap_or_else(|poisoned| poisoned.into_inner())
22976            .records
22977            .keys()
22978            .map(|record_id| record_id.as_str().to_owned())
22979            .collect()
22980    }
22981
22982    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22983        values.iter().map(|value| (*value).to_owned()).collect()
22984    }
22985
22986    #[tokio::test]
22987    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22988        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22989        shared.session_record_retention = SessionRecordRetentionConfig {
22990            age_ms: 0,
22991            keep_per_workspace: 1,
22992        };
22993        shared.retire_unavailable_session_records().await;
22994
22995        assert_eq!(
22996            retention_remaining_record_ids(&shared),
22997            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22998        );
22999        assert_eq!(
23000            retention_removed_record_ids(&shared),
23001            owned(&["a1", "a2", "b1", "b2"]),
23002        );
23003        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23004
23005        drop(shared);
23006        drop(runtime);
23007        std::fs::remove_dir_all(root).unwrap();
23008    }
23009
23010    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23011    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23012    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23013    /// 200s old against a 250s threshold) even though it fails the keep-N
23014    /// test too, proving retirement needs BOTH, not either.
23015    #[tokio::test]
23016    async fn session_record_retention_both_set_retires_only_the_intersection() {
23017        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23018        shared.session_record_retention = SessionRecordRetentionConfig {
23019            age_ms: 250_000,
23020            keep_per_workspace: 1,
23021        };
23022        shared.retire_unavailable_session_records().await;
23023
23024        assert_eq!(
23025            retention_remaining_record_ids(&shared),
23026            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23027        );
23028        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23029        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23030
23031        drop(shared);
23032        drop(runtime);
23033        std::fs::remove_dir_all(root).unwrap();
23034    }
23035
23036    #[tokio::test]
23037    async fn session_record_retention_both_disabled_is_a_no_op() {
23038        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23039        shared.session_record_retention = SessionRecordRetentionConfig::default();
23040        shared.retire_unavailable_session_records().await;
23041
23042        assert_eq!(
23043            retention_remaining_record_ids(&shared),
23044            owned(&[
23045                "a1", "a2", "a3", "b1", "b2", "b3",
23046                "decoy-live", "decoy-pending", "decoy-dormant",
23047            ]),
23048        );
23049        assert!(retention_removed_record_ids(&shared).is_empty());
23050        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23051
23052        drop(shared);
23053        drop(runtime);
23054        std::fs::remove_dir_all(root).unwrap();
23055    }
23056
23057    #[test]
23058    fn session_record_context_export_selects_one_exact_history_identity() {
23059        let candidates = vec![
23060            HistoryCandidateSummary {
23061                id: "candidate-other".to_owned(),
23062                session_id_hint: "provider-other".to_owned(),
23063                modified_at_unix_ms: Some(1),
23064            },
23065            HistoryCandidateSummary {
23066                id: "candidate-exact".to_owned(),
23067                session_id_hint: "provider-exact".to_owned(),
23068                modified_at_unix_ms: Some(2),
23069            },
23070        ];
23071        assert_eq!(
23072            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23073            "candidate-exact",
23074        );
23075    }
23076
23077    #[test]
23078    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23079        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23080            id: id.to_owned(),
23081            session_id_hint: hint.to_owned(),
23082            modified_at_unix_ms: None,
23083        };
23084        let missing = select_history_candidate(
23085            &[candidate("candidate-other", "provider-other")],
23086            Some("provider-exact"),
23087        )
23088        .unwrap_err();
23089        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23090        let ambiguous = select_history_candidate(
23091            &[
23092                candidate("candidate-a", "provider-exact"),
23093                candidate("candidate-b", "provider-exact"),
23094            ],
23095            Some("provider-exact"),
23096        )
23097        .unwrap_err();
23098        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23099    }
23100
23101    /// The PTY-sidecar-observation shape (no fleet provider today, but
23102    /// capability-keyed rather than vendor-locked) never carries a verified
23103    /// `provider_session`; its record-bound export instead falls back to
23104    /// "exactly one discovered candidate" (see
23105    /// `export_context_pack_for_session_record_inner`). This is the same
23106    /// ambiguity refusal as the identity-filtered path, just without an
23107    /// identity to filter by.
23108    #[test]
23109    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23110        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23111            id: id.to_owned(),
23112            session_id_hint: hint.to_owned(),
23113            modified_at_unix_ms: None,
23114        };
23115        assert_eq!(
23116            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23117                .unwrap(),
23118            "candidate-sole",
23119        );
23120        let missing = select_history_candidate(&[], None).unwrap_err();
23121        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23122        let ambiguous = select_history_candidate(
23123            &[
23124                candidate("candidate-a", "sidecar-session-a"),
23125                candidate("candidate-b", "sidecar-session-b"),
23126            ],
23127            None,
23128        )
23129        .unwrap_err();
23130        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23131    }
23132
23133    #[test]
23134    fn session_record_context_export_revalidation_rejects_record_change() {
23135        let session = terminal_address(1);
23136        let identity = ProviderSessionIdentity {
23137            key: ProviderSessionKey::SessionId,
23138            id: "provider-exact".to_owned(),
23139            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23140        };
23141        let mut expected = record("claude", "record-context-export");
23142        expected.state = ManagedSessionState::Live;
23143        expected.provider_session = Some(identity.clone());
23144        expected.active_session = Some(session.clone());
23145        let binding = SessionBinding {
23146            workspace_id: session.workspace_id.clone(),
23147            generation: session.session.generation,
23148            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23149            pending_resume: None,
23150            record_id: Some(expected.record_id.clone()),
23151            managed_worktree_lease_id: None,
23152            environment_profile: None,
23153            bundle: None,
23154            context: None,
23155            materialization_id: None,
23156        };
23157        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23158        assert!(session_record_context_export_binding_is_exact(
23159            &expected,
23160            &expected,
23161            Some(&identity),
23162            &session,
23163            &binding,
23164            &expected.provider,
23165            &current_root,
23166            false,
23167        ));
23168        let mut changed = expected.clone();
23169        changed.updated_at_unix_ms += 1;
23170        assert!(!session_record_context_export_binding_is_exact(
23171            &expected,
23172            &changed,
23173            Some(&identity),
23174            &session,
23175            &binding,
23176            &expected.provider,
23177            &current_root,
23178            false,
23179        ));
23180        // A same-instant timestamp bump alone (state and active_session
23181        // otherwise unchanged) is not the clean-detachment transition
23182        // either, even with the reactive flag on: only the exact Live ->
23183        // Dormant + active_session cleared shape qualifies.
23184        assert!(!session_record_context_export_binding_is_exact(
23185            &expected,
23186            &changed,
23187            Some(&identity),
23188            &session,
23189            &binding,
23190            &expected.provider,
23191            &current_root,
23192            true,
23193        ));
23194        let mut detached = expected.clone();
23195        detached.state = ManagedSessionState::Dormant;
23196        detached.active_session = None;
23197        detached.updated_at_unix_ms += 1;
23198        assert!(!session_record_context_export_binding_is_exact(
23199            &expected,
23200            &detached,
23201            Some(&identity),
23202            &session,
23203            &binding,
23204            &expected.provider,
23205            &current_root,
23206            false,
23207        ));
23208        assert!(session_record_context_export_binding_is_exact(
23209            &expected,
23210            &detached,
23211            Some(&identity),
23212            &session,
23213            &binding,
23214            &expected.provider,
23215            &current_root,
23216            true,
23217        ));
23218    }
23219
23220    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23221    /// for a record whose provider never carries a verified identity (the
23222    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23223    /// declares one today): `identity` is `None` throughout, and the
23224    /// exactness check falls back to comparing `provider_session` itself,
23225    /// which must still be `None` on both sides.
23226    #[test]
23227    fn session_record_context_export_binding_is_exact_without_identity() {
23228        let session = terminal_address(1);
23229        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23230        expected.state = ManagedSessionState::Live;
23231        expected.active_session = Some(session.clone());
23232        let binding = SessionBinding {
23233            workspace_id: session.workspace_id.clone(),
23234            generation: session.session.generation,
23235            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23236            pending_resume: None,
23237            record_id: Some(expected.record_id.clone()),
23238            managed_worktree_lease_id: None,
23239            environment_profile: None,
23240            bundle: None,
23241            context: None,
23242            materialization_id: None,
23243        };
23244        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23245        assert!(session_record_context_export_binding_is_exact(
23246            &expected,
23247            &expected,
23248            None,
23249            &session,
23250            &binding,
23251            &expected.provider,
23252            &current_root,
23253            false,
23254        ));
23255        // A record that gained an identity between the initial lookup and
23256        // this recheck no longer matches the `None` this shape expects to
23257        // keep seeing -- treated the same as any other mid-export drift.
23258        let mut drifted = expected.clone();
23259        drifted.provider_session = Some(ProviderSessionIdentity {
23260            key: ProviderSessionKey::SessionId,
23261            id: "unexpected-identity".to_owned(),
23262            transcript_path: None,
23263        });
23264        assert!(!session_record_context_export_binding_is_exact(
23265            &expected,
23266            &drifted,
23267            None,
23268            &session,
23269            &binding,
23270            &expected.provider,
23271            &current_root,
23272            false,
23273        ));
23274    }
23275
23276    #[test]
23277    fn session_record_context_export_preflight_requires_live_usable_source() {
23278        let mut source = record("claude", "record-context-export-preflight");
23279        source.state = ManagedSessionState::Live;
23280        assert!(session_record_context_export_source_is_usable(
23281            &source,
23282            &SessionStatus::Running,
23283            false,
23284        ));
23285        assert!(session_record_context_export_source_is_usable(
23286            &source,
23287            &SessionStatus::Exited { exit_code: Some(0) },
23288            false,
23289        ));
23290
23291        source.state = ManagedSessionState::Dormant;
23292        assert!(!session_record_context_export_source_is_usable(
23293            &source,
23294            &SessionStatus::Running,
23295            false,
23296        ));
23297        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23298        // the only caller that may treat a Dormant, cleanly-exited record as
23299        // still usable — the on-demand path above never does.
23300        assert!(session_record_context_export_source_is_usable(
23301            &source,
23302            &SessionStatus::Exited { exit_code: Some(0) },
23303            true,
23304        ));
23305        assert!(!session_record_context_export_source_is_usable(
23306            &source,
23307            &SessionStatus::Exited { exit_code: Some(0) },
23308            false,
23309        ));
23310        source.state = ManagedSessionState::Live;
23311        assert!(!session_record_context_export_source_is_usable(
23312            &source,
23313            &SessionStatus::Starting,
23314            false,
23315        ));
23316    }
23317
23318    #[test]
23319    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23320        let catalog = active_registry().unwrap();
23321        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23322        let workspace = WorkspaceConfig::new(
23323            WorkspaceId::new("primary").unwrap(),
23324            std::env::current_dir().unwrap(),
23325        )
23326        .unwrap();
23327        let shared = NodeShared::new(
23328            handle,
23329            "fixture-token".to_owned(),
23330            NodeId::new("node-context-export-conflict").unwrap(),
23331            vec![workspace],
23332            vec![agent("claude")],
23333        );
23334        let history = HistorySessionRecord {
23335            session_id: "provider-exact".to_owned(),
23336            title: None,
23337            cwd: None,
23338            model: None,
23339            message_count: 1,
23340            completed_turn_count: None,
23341            total_tokens: 0,
23342            messages: vec![gate4agent_types::HistoryMessageRecord {
23343                role: gate4agent_types::HistoryMessageRole::User,
23344                text: "bounded context".to_owned(),
23345            }],
23346        };
23347        let pack = NodeContextPack::export(
23348            ContextPackLineageReceipt {
23349                source_node_id: shared.node_id.clone(),
23350                source_session: terminal_address(1),
23351                source_provider: agent("claude"),
23352            },
23353            &history,
23354        )
23355        .unwrap();
23356        let context_id = pack.receipt().id.clone();
23357        let session = terminal_address(1);
23358        let identity = ProviderSessionIdentity {
23359            key: ProviderSessionKey::SessionId,
23360            id: history.session_id.clone(),
23361            transcript_path: None,
23362        };
23363        let mut expected = record("claude", "record-context-export-final-conflict");
23364        expected.state = ManagedSessionState::Live;
23365        expected.provider_session = Some(identity.clone());
23366        expected.active_session = Some(session.clone());
23367        let mut changed = expected.clone();
23368        changed.updated_at_unix_ms += 1;
23369        shared.insert_record(changed).unwrap();
23370
23371        let error = shared
23372            .commit_context_pack_for_session_record(
23373                &expected,
23374                Some(&identity),
23375                &session,
23376                "candidate-exact",
23377                &history,
23378                pack,
23379                false,
23380            )
23381            .unwrap_err();
23382
23383        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23384        assert!(shared
23385            .context_catalog
23386            .read()
23387            .unwrap_or_else(|poisoned| poisoned.into_inner())
23388            .get(&context_id)
23389            .is_none());
23390    }
23391
23392    #[tokio::test]
23393    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23394        let root = temporary_workspace_root("session-task-binding");
23395        std::fs::create_dir_all(&root).unwrap();
23396        let state_path = root.join("node-state.json");
23397        let catalog = active_registry().unwrap();
23398        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23399        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23400        let node_id = NodeId::new("node-1").unwrap();
23401        let mut shared = NodeShared::new(
23402            handle,
23403            "fixture-token".to_owned(),
23404            node_id.clone(),
23405            vec![workspace],
23406            vec![agent("claude")],
23407        );
23408        shared.state_path = Some(state_path.clone());
23409        let record = record("claude", "sr-task");
23410        let record_id = record.record_id.clone();
23411        shared.insert_record(record).unwrap();
23412        shared.persist_state().unwrap();
23413        shared
23414            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23415            .unwrap();
23416
23417        let minted = process_request_inner(
23418            &shared,
23419            77,
23420            ClientRole::Operator,
23421            NodeRequest::SetSessionTask {
23422                record_id: record_id.clone(),
23423                expected_revision: 0,
23424                target: SessionTaskTargetV1::New,
23425            },
23426        )
23427        .await
23428        .unwrap();
23429        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23430            panic!("session task mutation returned another response");
23431        };
23432        let minted_binding = minted.task_binding.clone().unwrap();
23433        let minted_task_id = minted_binding.task_id.clone().unwrap();
23434        assert_eq!(minted_binding.revision, 1);
23435        assert!(minted_task_id.as_str().starts_with("task-"));
23436        assert_eq!(minted_task_id.as_str().len(), 29);
23437
23438        let event_count = shared.current_sequence();
23439        let repeated = process_request_inner(
23440            &shared,
23441            77,
23442            ClientRole::Operator,
23443            NodeRequest::SetSessionTask {
23444                record_id: record_id.clone(),
23445                expected_revision: 1,
23446                target: SessionTaskTargetV1::Existing {
23447                    task_id: minted_task_id.clone(),
23448                },
23449            },
23450        )
23451        .await
23452        .unwrap();
23453        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23454        assert_eq!(shared.current_sequence(), event_count);
23455
23456        let stale = process_request_inner(
23457            &shared,
23458            77,
23459            ClientRole::Operator,
23460            NodeRequest::SetSessionTask {
23461                record_id: record_id.clone(),
23462                expected_revision: 0,
23463                target: SessionTaskTargetV1::Existing {
23464                    task_id: minted_task_id,
23465                },
23466            },
23467        )
23468        .await
23469        .unwrap_err();
23470        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23471
23472        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23473        let assigned = process_request_inner(
23474            &shared,
23475            77,
23476            ClientRole::Operator,
23477            NodeRequest::SetSessionTask {
23478                record_id: record_id.clone(),
23479                expected_revision: 1,
23480                target: SessionTaskTargetV1::Existing {
23481                    task_id: assigned_id.clone(),
23482                },
23483            },
23484        )
23485        .await
23486        .unwrap();
23487        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23488            panic!("session task assignment returned another response");
23489        };
23490        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23491        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23492
23493        let cleared = process_request_inner(
23494            &shared,
23495            77,
23496            ClientRole::Operator,
23497            NodeRequest::SetSessionTask {
23498                record_id: record_id.clone(),
23499                expected_revision: 2,
23500                target: SessionTaskTargetV1::Clear,
23501            },
23502        )
23503        .await
23504        .unwrap();
23505        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23506            panic!("session task clear returned another response");
23507        };
23508        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23509        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23510        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23511        assert_eq!(
23512            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23513            cleared.updated_at_unix_ms,
23514        );
23515
23516        let persisted = std::fs::read(&state_path).unwrap();
23517        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23518        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23519        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23520        std::fs::remove_dir_all(root).unwrap();
23521    }
23522
23523    #[test]
23524    fn session_task_capability_strips_snapshots_events_and_replies() {
23525        let mut bound = record("claude", "sr-task-projection");
23526        bound.updated_at_unix_ms = 2;
23527        bound.task_binding = Some(SessionTaskBindingV1 {
23528            revision: 1,
23529            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23530            changed_at_unix_ms: 2,
23531        });
23532        let request = NodeRequest::SetSessionTask {
23533            record_id: bound.record_id.clone(),
23534            expected_revision: 1,
23535            target: SessionTaskTargetV1::Clear,
23536        };
23537        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23538            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23539        }));
23540        assert!(!request_uses_unnegotiated_capability(
23541            &request,
23542            &baseline_capabilities().unwrap(),
23543        ));
23544        assert!(request_uses_unnegotiated_capability(&request, &[]));
23545
23546        let mut snapshot = terminal_test_shared().snapshot();
23547        snapshot.session_records.push(bound.clone());
23548        let mut reply = ResponseEnvelope {
23549            request_id: 1,
23550            result: Ok(NodeResponse::Resync {
23551                event_sequence: 1,
23552                oldest_available_sequence: 1,
23553                snapshot,
23554                events: vec![NodeEventEnvelope {
23555                    sequence: 1,
23556                    event: NodeEvent::SessionRecordUpserted {
23557                        record: bound.clone(),
23558                    },
23559                }],
23560            }),
23561        };
23562        project_response_without_session_task_binding(&mut reply);
23563        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23564            panic!("task projection changed the response kind");
23565        };
23566        assert!(snapshot.session_records[0].task_binding.is_none());
23567        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23568            panic!("task projection changed the event kind");
23569        };
23570        assert!(record.task_binding.is_none());
23571
23572        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23573            sequence: 2,
23574            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23575        });
23576        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23577            panic!("task event projection changed the event kind");
23578        };
23579        assert!(record.task_binding.is_none());
23580
23581        let mut direct = ResponseEnvelope {
23582            request_id: 2,
23583            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23584        };
23585        project_response_without_session_task_binding(&mut direct);
23586        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23587            panic!("task reply projection changed the response kind");
23588        };
23589        assert!(record.task_binding.is_none());
23590    }
23591
23592    #[test]
23593    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23594        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23595        let workspace = WorkspaceConfig::new(
23596            WorkspaceId::new("primary").unwrap(),
23597            std::env::current_dir().unwrap(),
23598        )
23599        .unwrap();
23600        let config = NodeServerConfig::new(
23601            DEFAULT_NODE_ENDPOINT,
23602            "fixture-token",
23603            NodeId::new("node-1").unwrap(),
23604            [workspace],
23605        )
23606        .unwrap();
23607        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23608    }
23609
23610    #[cfg(all(feature = "fixture", windows))]
23611    #[test]
23612    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23613        let root = temporary_workspace_root("provider-bundle-argv-hold");
23614        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23615        std::fs::create_dir_all(&root).unwrap();
23616        std::fs::create_dir_all(&outside).unwrap();
23617        let proof_path = root.join("provider.proof");
23618        let release_signal = root.join("release.signal");
23619        let config = NodeServerConfig::new(
23620            format!(
23621                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23622                std::process::id(),
23623            ),
23624            "fixture-token",
23625            NodeId::new("provider-bundle-hold-node").unwrap(),
23626            [WorkspaceConfig::new(
23627                WorkspaceId::new("primary").unwrap(),
23628                std::env::current_dir().unwrap(),
23629            )
23630            .unwrap()],
23631        )
23632        .unwrap()
23633        .with_spawn_profiles(SpawnProfileRegistry::new([
23634            crate::protocol::SpawnProfileDefaults {
23635                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23636                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23637                    .unwrap(),
23638                provider: agent("kimi"),
23639                mode: SessionMode::Pty,
23640                terminal_size: gate4agent_types::TerminalSize {
23641                    rows: 24,
23642                    columns: 80,
23643                },
23644                prompt: None,
23645                bundle_id: None,
23646                context_id: None,
23647                environment_profile_id: None,
23648            },
23649        ])
23650        .unwrap());
23651        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23652            config,
23653            agent("kimi"),
23654            proof_path.clone(),
23655            release_signal.clone(),
23656        )
23657        .unwrap();
23658        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23659
23660        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23661            agent("kimi"),
23662            proof_path.clone(),
23663            Some(release_signal.clone()),
23664        )
23665        .unwrap();
23666        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23667        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23668        let release_wait = wrapper
23669            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23670            .unwrap();
23671        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23672        assert!(proof_write < release_wait);
23673        assert!(release_wait < fixture_ready);
23674        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23675        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23676        assert_eq!(
23677            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23678            &[
23679                proof_path.to_string_lossy().into_owned(),
23680                release_signal.to_string_lossy().into_owned(),
23681            ],
23682        );
23683
23684        let error = NodeServer::provider_bundle_argv_fixture_spec(
23685            agent("kimi"),
23686            proof_path,
23687            Some(outside.join("release.signal")),
23688        )
23689        .unwrap_err();
23690        assert!(matches!(
23691            error,
23692            NodeServerError::Registry(message)
23693                if message == "provider bundle argv release path must share the proof parent"
23694        ));
23695        drop(server);
23696        std::fs::remove_dir_all(root).unwrap();
23697        std::fs::remove_dir_all(outside).unwrap();
23698    }
23699
23700    #[cfg(feature = "fixture")]
23701    #[test]
23702    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23703        let root = temporary_workspace_root("context-fixture-registry");
23704        std::fs::create_dir_all(&root).unwrap();
23705        let proof_path = root.join("context.proof");
23706        let config = NodeServerConfig::new(
23707            format!(
23708                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23709                std::process::id(),
23710            ),
23711            "fixture-token",
23712            NodeId::new("context-fixture-node").unwrap(),
23713            [WorkspaceConfig::new(
23714                WorkspaceId::new("primary").unwrap(),
23715                std::env::current_dir().unwrap(),
23716            )
23717            .unwrap()],
23718        )
23719        .unwrap();
23720        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23721        let mut enabled = server
23722            .shared
23723            .enabled_providers
23724            .iter()
23725            .map(AgentId::as_str)
23726            .collect::<Vec<_>>();
23727        enabled.sort_unstable();
23728        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23729        for provider in &server.shared.enabled_providers {
23730            assert!(server
23731                .shared
23732                .provider_adapter_contracts
23733                .iter()
23734                .any(|contract| {
23735                    &contract.provider == provider
23736                        && contract.family == AdapterFamily::History
23737                }));
23738        }
23739        drop(server);
23740        std::fs::remove_dir_all(root).unwrap();
23741    }
23742
23743    #[cfg(feature = "fixture")]
23744    #[test]
23745    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23746        let root = temporary_workspace_root("monitoring-context-fixture");
23747        std::fs::create_dir_all(&root).unwrap();
23748        let proof_path = root.join("context.proof");
23749        let standard = NodeServer::context_pack_fixture_catalog(
23750            proof_path.clone(),
23751            None,
23752            false,
23753        ).unwrap();
23754        let monitoring = NodeServer::context_pack_fixture_catalog(
23755            proof_path.clone(),
23756            None,
23757            true,
23758        ).unwrap();
23759        let claude = AgentId::new("claude").unwrap();
23760        let standard_claude = standard.get(&claude).unwrap();
23761        let monitoring_claude = monitoring.get(&claude).unwrap();
23762        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23763        // standard nor the monitoring fixture declares a hook adapter
23764        // anymore.
23765        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23766        assert_eq!(
23767            monitoring_claude.capabilities.adapters.history,
23768            standard_claude.capabilities.adapters.history,
23769        );
23770        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23771        let codex = AgentId::new("codex").unwrap();
23772        let standard_codex = standard.get(&codex).unwrap();
23773        let monitoring_codex = monitoring.get(&codex).unwrap();
23774        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23775        assert_eq!(
23776            monitoring_codex.capabilities.adapters.history,
23777            standard_codex.capabilities.adapters.history,
23778        );
23779        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23780        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23781            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23782            .unwrap();
23783        for required in [
23784            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23785            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23786            "@('role', 'text')",
23787            "g4a-private-provider-session-canary",
23788            "private-provider-login-identity",
23789            "$rawProof",
23790            "$env:GATE4AGENT_HOOK_URL",
23791            "g4a-private-provider-session-canary",
23792        ] {
23793            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23794        }
23795        for provider in ["grok", "kimi"] {
23796            let provider = AgentId::new(provider).unwrap();
23797            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23798        }
23799
23800        let config = NodeServerConfig::new(
23801            format!(
23802                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23803                std::process::id(),
23804            ),
23805            "fixture-token",
23806            NodeId::new("monitoring-context-fixture-node").unwrap(),
23807            [WorkspaceConfig::new(
23808                WorkspaceId::new("primary").unwrap(),
23809                std::env::current_dir().unwrap(),
23810            )
23811            .unwrap()],
23812        )
23813        .unwrap();
23814        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23815        assert!(server.shared.fixture_semantic_hook_policy);
23816        drop(server);
23817        std::fs::remove_dir_all(root).unwrap();
23818    }
23819
23820    #[cfg(feature = "fixture")]
23821    #[test]
23822    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23823        let root = temporary_workspace_root("kimi-context-only-fixture");
23824        std::fs::create_dir_all(&root).unwrap();
23825        let proof_path = root.join("context.proof");
23826        let standard = NodeServer::context_pack_fixture_catalog(
23827            proof_path.clone(),
23828            None,
23829            false,
23830        ).unwrap();
23831        let kimi = AgentId::new("kimi").unwrap();
23832        let context_only = NodeServer::context_pack_fixture_catalog(
23833            proof_path,
23834            Some(&kimi),
23835            false,
23836        )
23837        .unwrap();
23838
23839        let codex = AgentId::new("codex").unwrap();
23840        assert_eq!(
23841            standard.get(&codex).unwrap().launch.fixed_args,
23842            context_only.get(&codex).unwrap().launch.fixed_args,
23843        );
23844        let script = context_only
23845            .get(&kimi)
23846            .unwrap()
23847            .launch
23848            .fixed_args
23849            .iter()
23850            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23851            .expect("Kimi context-only validation script is missing");
23852        for required in [
23853            "$bundleArgs.Count -ne 0",
23854            "GATE4AGENT_CONTEXT_ROOT",
23855            "context-pack.json",
23856            "$entries.Count -ne 1",
23857            "g4a-context-pack-v1",
23858            "$document.PSObject.Properties['cwd']",
23859            "'claude', 'codex', 'grok', 'kimi'",
23860            "$hasUser",
23861            "$hasAssistant",
23862            "SHA256",
23863            "context-only",
23864        ] {
23865            assert!(script.contains(required), "missing validation: {required}");
23866        }
23867        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23868            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23869        }
23870        assert!(!standard
23871            .get(&kimi)
23872            .unwrap()
23873            .launch
23874            .fixed_args
23875            .iter()
23876            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23877
23878        std::fs::remove_dir_all(root).unwrap();
23879    }
23880
23881    #[cfg(all(feature = "fixture", windows))]
23882    #[tokio::test]
23883    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23884        let root = temporary_workspace_root("kimi-context-only-spawn");
23885        let workspace_root = root.join("workspace");
23886        std::fs::create_dir_all(&workspace_root).unwrap();
23887        let materialization_root = root.join("private-materializations");
23888        let proof_path = root.join("context.proof");
23889        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23890        let workspace_id = WorkspaceId::new("primary").unwrap();
23891        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23892        let endpoint = format!(
23893            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23894            std::process::id(),
23895            unix_time_ms(),
23896        );
23897        let spawn_profiles = SpawnProfileRegistry::new([
23898            crate::protocol::SpawnProfileDefaults {
23899                profile_id: profile_id.clone(),
23900                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23901                provider: agent("kimi"),
23902                mode: SessionMode::Pty,
23903                terminal_size: gate4agent_types::TerminalSize {
23904                    rows: 30,
23905                    columns: 120,
23906                },
23907                prompt: None,
23908                bundle_id: None,
23909                context_id: None,
23910                environment_profile_id: None,
23911            },
23912        ])
23913        .unwrap();
23914        let mut config = NodeServerConfig::new(
23915            &endpoint,
23916            "fixture-token",
23917            node_id.clone(),
23918            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23919        )
23920        .unwrap()
23921        .with_spawn_profiles(spawn_profiles)
23922        .with_session_environment_materialization(
23923            materialization_root,
23924            Arc::new(FixtureSecretResolver {
23925                deny: Arc::new(AtomicBool::new(false)),
23926            }),
23927        )
23928        .unwrap();
23929        config.fixture_raw_pty_runtime = true;
23930        let server = NodeServer::new_context_only_proof_fixture(
23931            config,
23932            agent("kimi"),
23933            proof_path.clone(),
23934        )
23935        .unwrap();
23936        let shared = Arc::clone(&server.shared);
23937        let shutdown = server.shutdown_handle();
23938
23939        let history = HistorySessionRecord {
23940            session_id: "context-only-source-history".to_owned(),
23941            title: None,
23942            cwd: None,
23943            model: None,
23944            message_count: 2,
23945            completed_turn_count: None,
23946            total_tokens: 0,
23947            messages: vec![
23948                gate4agent_types::HistoryMessageRecord {
23949                    role: gate4agent_types::HistoryMessageRole::User,
23950                    text: "continue the bounded parent run through native C2".to_owned(),
23951                },
23952                gate4agent_types::HistoryMessageRecord {
23953                    role: gate4agent_types::HistoryMessageRole::Assistant,
23954                    text: "the exact parent context is ready for the child run".to_owned(),
23955                },
23956            ],
23957        };
23958        let pack = NodeContextPack::export(
23959            ContextPackLineageReceipt {
23960                source_node_id: node_id.clone(),
23961                source_session: SessionAddress {
23962                    workspace_id: workspace_id.clone(),
23963                    session: SessionKey {
23964                        instance_id: AgentInstanceId(71),
23965                        generation: SessionGeneration(1),
23966                    },
23967                },
23968                source_provider: agent("codex"),
23969            },
23970            &history,
23971        )
23972        .unwrap();
23973        let expected_context_hash = digest(&SHA256, pack.bytes())
23974            .as_ref()
23975            .iter()
23976            .map(|byte| format!("{byte:02x}"))
23977            .collect::<String>();
23978        let context = shared
23979            .context_catalog
23980            .write()
23981            .unwrap_or_else(|poisoned| poisoned.into_inner())
23982            .insert(pack)
23983            .unwrap();
23984        let spec = SpawnSpec {
23985            target: crate::protocol::SpawnTarget {
23986                node_id,
23987                workspace_id: workspace_id.clone(),
23988                worktree_id: None,
23989            },
23990            profile_id,
23991            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23992                "target-kimi-r1",
23993            ).unwrap(),
23994            overrides: crate::protocol::SpawnOverrides {
23995                context_id: crate::protocol::SpawnOverride::Set {
23996                    value: context.id.clone(),
23997                },
23998                ..crate::protocol::SpawnOverrides::default()
23999            },
24000            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24001            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24002            required_capabilities: SpawnRequiredCapabilities::default(),
24003        };
24004        let resolved = match shared.resolve_spawn_spec(&spec) {
24005            Ok(resolved) => resolved,
24006            Err(error) => panic!(
24007                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24008                error.code,
24009                error.message,
24010            ),
24011        };
24012        let resolved_context = match shared.resolve_context(&resolved) {
24013            Ok(Some(resolved_context)) => resolved_context,
24014            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24015            Err(error) => panic!(
24016                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24017                error.code,
24018                error.message,
24019            ),
24020        };
24021        assert_eq!(resolved_context, context);
24022        let runtime_policy = match shared
24023            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24024            .await
24025        {
24026            Ok(runtime_policy) => runtime_policy,
24027            Err(error) => panic!(
24028                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24029                error.code,
24030                error.message,
24031            ),
24032        };
24033        assert!(runtime_policy.raw_pty_lifecycle);
24034
24035        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24036        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24037            agent("kimi"),
24038            TransportKind::Pty,
24039            vec![EnvMutation {
24040                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24041                value: Some(OsString::from("must-remain-rejected")),
24042            }],
24043        )
24044        .unwrap();
24045        assert_eq!(
24046            launch_control
24047                .install_native_launch_environment_overlay(
24048                    AgentInstanceId(72),
24049                    generic_overlay,
24050                )
24051                .unwrap_err(),
24052            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24053        );
24054
24055        let diagnostic_address = SessionAddress {
24056            workspace_id: workspace_id.clone(),
24057            session: SessionKey {
24058                instance_id: AgentInstanceId(73),
24059                generation: SessionGeneration(1),
24060            },
24061        };
24062        let (diagnostic_overlay, diagnostic_materialization) = match shared
24063            .prepare_session_materialization(
24064                &diagnostic_address,
24065                &agent("kimi"),
24066                SessionMode::Pty,
24067                None,
24068                None,
24069                Some(&resolved_context),
24070                None,
24071            )
24072        {
24073            Ok(Some(prepared)) => prepared,
24074            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24075            Err(error) => panic!(
24076                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24077                error.code,
24078                error.message,
24079            ),
24080        };
24081        let diagnostic_overlay = diagnostic_overlay
24082            .expect("Kimi context-only materialization omitted its environment overlay");
24083        assert!(matches!(
24084            &diagnostic_overlay,
24085            PreparedNativeLaunchOverlay::Environment(_),
24086        ));
24087        match shared.install_prepared_launch_overlay(
24088            diagnostic_address.session.instance_id,
24089            diagnostic_overlay,
24090        ) {
24091            Ok(None) => {}
24092            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24093            Err(error) => panic!(
24094                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24095                error.code,
24096                error.message,
24097            ),
24098        }
24099        assert!(launch_control.clear_native_instance_launch_overlay(
24100            diagnostic_address.session.instance_id,
24101        ));
24102        drop(diagnostic_materialization);
24103        assert!(shared
24104            .materializations
24105            .lock()
24106            .unwrap_or_else(|poisoned| poisoned.into_inner())
24107            .is_empty());
24108
24109        let node_task = tokio::spawn(server.run());
24110        let ready_client = timeout(Duration::from_secs(5), async {
24111            loop {
24112                match gate4agent_node_wire::LocalNodeClient::connect(
24113                    &endpoint,
24114                    &shared.node_id,
24115                    ClientRole::Observer,
24116                    "fixture-token",
24117                )
24118                .await
24119                {
24120                    Ok(client) => break client,
24121                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24122                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24123                }
24124            }
24125        })
24126        .await
24127        .expect("Kimi context-only Node did not become ready");
24128        assert_eq!(
24129            ready_client
24130                .hello()
24131                .snapshot
24132                .provider_runtime_statuses
24133                .iter()
24134                .find(|status| status.provider().as_str() == "kimi")
24135                .expect("Kimi runtime status is missing")
24136                .mode(),
24137            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24138        );
24139        drop(ready_client);
24140
24141        let receipt = match shared.spawn_from_spec(spec).await {
24142            Ok(receipt) => receipt,
24143            Err(error) => panic!(
24144                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24145                error.code,
24146                error.message,
24147            ),
24148        };
24149        assert_eq!(receipt.provider, agent("kimi"));
24150        assert_eq!(receipt.context.as_ref(), Some(&context));
24151        assert!(receipt.context_binding_is_valid());
24152        timeout(Duration::from_secs(5), async {
24153            while !proof_path.is_file() {
24154                sleep(Duration::from_millis(10)).await;
24155            }
24156        })
24157        .await
24158        .expect("Kimi context-only fixture did not write its proof");
24159
24160        let proof = std::fs::read_to_string(&proof_path).unwrap();
24161        let lines = proof.lines().collect::<Vec<_>>();
24162        assert_eq!(lines.len(), 7);
24163        assert_eq!(lines[0], "context-only");
24164        assert!(Path::new(lines[1]).is_absolute());
24165        assert_ne!(
24166            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24167            std::fs::canonicalize(&workspace_root).unwrap(),
24168        );
24169        assert_eq!(
24170            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24171            std::fs::canonicalize(&workspace_root).unwrap(),
24172        );
24173        assert_eq!(lines[3], expected_context_hash);
24174        assert_eq!(lines[4], "g4a-context-pack-v1");
24175        assert_eq!(lines[5], "codex");
24176        assert_eq!(lines[6], "2");
24177        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24178            session.instance_id == receipt.session.session.instance_id
24179                && session.agent_id.as_str() == "kimi"
24180        }));
24181
24182        shutdown.request_shutdown().await.unwrap();
24183        timeout(Duration::from_secs(5), node_task)
24184            .await
24185            .unwrap()
24186            .unwrap()
24187            .unwrap();
24188        drop(shutdown);
24189        drop(shared);
24190        std::fs::remove_dir_all(root).unwrap();
24191    }
24192
24193    #[cfg(feature = "fixture")]
24194    #[test]
24195    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24196        let root = temporary_workspace_root("clean-exit-constructor");
24197        std::fs::create_dir_all(&root).unwrap();
24198        let config = construction_test_config("clean-exit-constructor")
24199            .with_state_path(root.join("node-state.json"))
24200            .unwrap();
24201        let server = NodeServer::new_clean_exit_fixture(
24202            config,
24203            root.clone(),
24204            root.join("started.marker"),
24205            root.join("release.signal"),
24206        )
24207        .unwrap();
24208        assert_eq!(
24209            server
24210                .shared
24211                .enabled_providers
24212                .iter()
24213                .map(AgentId::as_str)
24214                .collect::<Vec<_>>(),
24215            ["claude"],
24216        );
24217        drop(server);
24218        std::fs::remove_dir_all(root).unwrap();
24219    }
24220
24221    #[test]
24222    fn production_registry_provider_contract_manifest_is_exact() {
24223        let registry = active_registry().unwrap();
24224        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24225        assert_eq!(
24226            providers
24227                .iter()
24228                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24229                .collect::<Vec<_>>(),
24230            vec![
24231                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24232                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24233                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24234                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24235            ]
24236        );
24237        assert_eq!(
24238            adapters
24239                .iter()
24240                .map(|contract| (
24241                    contract.provider.as_str(),
24242                    contract.family,
24243                    contract.adapter_id.as_str(),
24244                    contract.revision.as_str(),
24245                ))
24246                .collect::<Vec<_>>(),
24247            vec![
24248                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24249                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24250                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24251                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24252                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24253                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24254                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24255                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24256                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24257                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24258                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24259                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24260                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24261                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24262                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24263                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24264                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24265                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24266                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24267                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24268                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24269                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24270                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24271            ]
24272        );
24273    }
24274
24275    fn construction_test_config(name: &str) -> NodeServerConfig {
24276        let workspace = WorkspaceConfig::new(
24277            WorkspaceId::new("primary").unwrap(),
24278            std::env::current_dir().unwrap(),
24279        )
24280        .unwrap();
24281        NodeServerConfig::new(
24282            format!(r"\\.\pipe\gate4agent-{name}"),
24283            "fixture-token",
24284            NodeId::new(name).unwrap(),
24285            [workspace],
24286        )
24287        .unwrap()
24288    }
24289
24290    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24291        let adapter = AdapterBinding::new(
24292            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24293            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24294            gate4agent_types::AdapterVerification::SyntheticFixture,
24295        )
24296        .unwrap();
24297        let agent_ids = ["claude", "codex", "kimi"]
24298            .into_iter()
24299            .map(|agent_id| AgentId::new(agent_id).unwrap())
24300            .collect::<Vec<_>>();
24301        let adapters = gate4agent_catalog::AdapterRegistry::new(
24302            [
24303                AdapterFamily::PtySemantic,
24304                AdapterFamily::Pipe,
24305                AdapterFamily::OneShot,
24306                AdapterFamily::Acp,
24307                AdapterFamily::Hook,
24308                AdapterFamily::ManagedHook,
24309                AdapterFamily::History,
24310                AdapterFamily::Resume,
24311                AdapterFamily::SessionOptions,
24312                AdapterFamily::CapabilityProbe,
24313            ]
24314            .into_iter()
24315            .map(|family| gate4agent_catalog::AdapterDescriptor {
24316                family,
24317                binding: adapter.clone(),
24318                agents: agent_ids.clone(),
24319            }),
24320        )
24321        .unwrap();
24322        let specs = agent_ids
24323            .iter()
24324            .map(|agent_id| {
24325                let agent_id = agent_id.as_str();
24326                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24327                spec.revision =
24328                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24329                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24330                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24331                    adapter: adapter.clone(),
24332                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24333                    launch_override: None,
24334                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24335                });
24336                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24337                    adapter: adapter.clone(),
24338                    launch_override: None,
24339                });
24340                spec.capabilities.adapters.hook = Some(adapter.clone());
24341                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24342                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24343                spec.capabilities.adapters.history = Some(adapter.clone());
24344                spec.capabilities.adapters.resume = Some(adapter.clone());
24345                spec.capabilities.adapters.session_options = Some(adapter.clone());
24346                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24347                spec
24348            })
24349            .collect::<Vec<_>>();
24350        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24351    }
24352
24353    /// The real fleet's manifest is accepted by a real server construction.
24354    ///
24355    /// The count is asserted against `active_registry` itself rather than a
24356    /// literal, so this stays a test that construction succeeds for whatever
24357    /// the fleet is -- which is the property worth guarding -- instead of a
24358    /// second place the fleet size is written down and has to be kept in
24359    /// step with the first.
24360    #[test]
24361    fn node_server_construction_accepts_the_production_manifest() {
24362        let registry = active_registry().unwrap();
24363        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24364        assert_eq!(providers.len(), registry.iter().count());
24365        assert!(!providers.is_empty());
24366        let server = NodeServer::new_with_registry(
24367            construction_test_config("manifest-production-bound"),
24368            registry,
24369        )
24370        .unwrap();
24371        drop(server);
24372    }
24373
24374    #[test]
24375    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24376        let registry = maximum_size_provider_manifest_registry();
24377        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24378        assert_eq!(providers.len(), 3);
24379        assert_eq!(adapters.len(), 30);
24380        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24381        let error = match NodeServer::new_with_registry(
24382            construction_test_config("manifest-handshake-overflow"),
24383            registry,
24384        ) {
24385            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24386            Err(error) => error,
24387        };
24388        assert!(matches!(
24389            error,
24390            NodeServerError::ProviderContractManifest(message)
24391                if message.contains("authentication binding length")
24392                    && message.contains("8192-byte limit")
24393        ));
24394    }
24395
24396    #[test]
24397    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24398        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24399        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24400        let registry = AgentRegistry::new([spec]).unwrap();
24401        assert!(matches!(
24402            provider_contract_manifest(&registry),
24403            Err(NodeServerError::ProviderContractManifest(message))
24404                if message.contains("exceeds the 128-byte limit")
24405        ));
24406    }
24407
24408    #[test]
24409    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24410        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24411        spec.revision = "invalid revision".to_owned();
24412        let registry = AgentRegistry::new([spec]).unwrap();
24413        assert!(matches!(
24414            provider_contract_manifest(&registry),
24415            Err(NodeServerError::ProviderContractManifest(message))
24416                if message.contains("bounded lowercase ASCII")
24417        ));
24418    }
24419
24420    #[test]
24421    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24422        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24423        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24424        let conflicting_binding = builtin_registry()
24425            .get_by_id("codex")
24426            .unwrap()
24427            .capabilities
24428            .adapters
24429            .one_shot
24430            .clone()
24431            .unwrap();
24432        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24433        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24434        pipe.adapter = transport_binding;
24435        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24436        let registry = AgentRegistry::new([spec]).unwrap();
24437        assert!(matches!(
24438            provider_contract_manifest(&registry),
24439            Err(NodeServerError::ProviderContractManifest(message))
24440                if message.contains("conflicting OneShot adapter bindings")
24441        ));
24442    }
24443
24444    #[test]
24445    fn public_node_failures_expose_only_fixed_categories() {
24446        let provider = failure(
24447            NodeFailureCode::BackendOperationFailed,
24448            r"provider bearer-secret failed at C:\private\session.jsonl",
24449        );
24450        assert_eq!(provider.message, "backend-operation-failed");
24451        assert!(!provider.message.contains("bearer-secret"));
24452        assert!(!provider.message.contains(r"C:\private"));
24453        let persistence = persistence_failure(io::Error::new(
24454            io::ErrorKind::Other,
24455            r"state write failed at C:\private\state-v1.json",
24456        ));
24457        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24458        assert!(!persistence.message.contains(r"C:\private"));
24459
24460        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24461            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24462            assert_eq!(
24463                generic.to_string(),
24464                "node durable state failed: durable-state-load-failed",
24465            );
24466        }
24467    }
24468
24469    #[test]
24470    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24471        let sequence = b"\x1b[1;5D".to_vec();
24472        assert_eq!(
24473            terminal_bytes_action(sequence.clone()).unwrap(),
24474            InputAction::TerminalBytes(sequence),
24475        );
24476        assert_eq!(
24477            terminal_bytes_action(Vec::new()).unwrap_err().code,
24478            NodeFailureCode::InvalidRequest,
24479        );
24480        assert_eq!(
24481            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24482                .unwrap_err()
24483                .code,
24484            NodeFailureCode::InvalidRequest,
24485        );
24486    }
24487
24488    #[test]
24489    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24490        let root = temporary_workspace_root("exclusive-state-owner");
24491        std::fs::create_dir_all(&root).unwrap();
24492        let workspace = WorkspaceConfig::new(
24493            WorkspaceId::new("primary").unwrap(),
24494            &root,
24495        )
24496        .unwrap();
24497        let state_path = root.join("state-v1.json");
24498        let endpoint = format!(
24499            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24500            std::process::id(),
24501            std::time::SystemTime::now()
24502                .duration_since(std::time::UNIX_EPOCH)
24503                .unwrap()
24504                .as_nanos(),
24505        );
24506        let config = NodeServerConfig::new(
24507            endpoint,
24508            "fixture-token",
24509            NodeId::new("node-1").unwrap(),
24510            [workspace],
24511        )
24512        .unwrap()
24513        .with_state_path(&state_path)
24514        .unwrap();
24515
24516        let first = NodeServer::new(config.clone()).unwrap();
24517        let error = match NodeServer::new(config.clone()) {
24518            Ok(_) => panic!("a second node server acquired the same durable state path"),
24519            Err(error) => error,
24520        };
24521        assert!(matches!(
24522            &error,
24523            NodeServerError::DurableState(error)
24524                if error.kind() == io::ErrorKind::WouldBlock
24525        ));
24526        assert_eq!(
24527            error.to_string(),
24528            "node durable state failed: durable-state-lock-failed",
24529        );
24530        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24531        drop(first);
24532        let second = NodeServer::new(config).unwrap();
24533        drop(second);
24534        std::fs::remove_dir_all(root).unwrap();
24535    }
24536
24537    #[test]
24538    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24539        let catalog = active_registry().unwrap();
24540        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24541        let workspace_id = WorkspaceId::new("primary").unwrap();
24542        let workspace = WorkspaceConfig::new(
24543            workspace_id.clone(),
24544            std::env::current_dir().unwrap(),
24545        )
24546        .unwrap();
24547        let shared = NodeShared::new(
24548            handle,
24549            "fixture-token".to_owned(),
24550            NodeId::new("node-1").unwrap(),
24551            vec![workspace.clone()],
24552            vec![agent("claude"), agent("codex"), agent("kimi")],
24553        );
24554        let instance_id = AgentInstanceId(17);
24555        let generation = SessionGeneration(3);
24556        assert!(shared.address_for(instance_id, generation).is_none());
24557        shared.bind_session(instance_id, workspace_id.clone(), generation);
24558        let address = SessionAddress {
24559            workspace_id,
24560            session: SessionKey { instance_id, generation },
24561        };
24562        assert_eq!(
24563            shared.workspace_root(&address.workspace_id).unwrap(),
24564            workspace.canonical_root,
24565        );
24566        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24567        shared.remove_binding(&address);
24568        assert!(shared.address_for(instance_id, generation).is_none());
24569    }
24570
24571    #[test]
24572    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24573        let address = SessionAddress {
24574            workspace_id: WorkspaceId::new("primary").unwrap(),
24575            session: SessionKey {
24576                instance_id: AgentInstanceId(7),
24577                generation: SessionGeneration(2),
24578            },
24579        };
24580        let record_id = SessionRecordId::new("record-a").unwrap();
24581        let size = gate4agent_types::TerminalSize {
24582            rows: 24,
24583            columns: 80,
24584        };
24585        let address_requests = [
24586            NodeRequest::Resume {
24587                session: address.clone(),
24588                terminal_size: size,
24589                initial_prompt: None,
24590            },
24591            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24592            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24593            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24594            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24595            NodeRequest::TerminalControl {
24596                session: address.clone(),
24597                control: TerminalControl::Enter,
24598            },
24599            NodeRequest::Resize { session: address.clone(), size },
24600            NodeRequest::Interrupt { session: address.clone() },
24601            NodeRequest::Stop { session: address.clone(), force: false },
24602            NodeRequest::Remove { session: address },
24603        ];
24604        for request in &address_requests {
24605            assert!(request_requires_open_provider_ids_with(
24606                request,
24607                |_| Some(agent("third-party-agent")),
24608                |_| Some(agent("claude")),
24609            ));
24610            assert!(!request_requires_open_provider_ids_with(
24611                request,
24612                |_| Some(agent("claude")),
24613                |_| Some(agent("claude")),
24614            ));
24615            assert!(request_requires_open_provider_ids_with(
24616                request,
24617                |_| None,
24618                |_| Some(agent("claude")),
24619            ));
24620        }
24621
24622        let record_requests = [
24623            NodeRequest::RenameSessionRecord {
24624                record_id: record_id.clone(),
24625                display_name: "renamed".to_owned(),
24626            },
24627            NodeRequest::ResumeSessionRecord {
24628                record_id: record_id.clone(),
24629                terminal_size: size,
24630                initial_prompt: None,
24631            },
24632            NodeRequest::ForgetSessionRecord { record_id },
24633        ];
24634        for request in &record_requests {
24635            assert!(request_requires_open_provider_ids_with(
24636                request,
24637                |_| Some(agent("claude")),
24638                |_| Some(agent("grok")),
24639            ));
24640            assert!(!request_requires_open_provider_ids_with(
24641                request,
24642                |_| Some(agent("claude")),
24643                |_| Some(agent("kimi")),
24644            ));
24645            assert!(request_requires_open_provider_ids_with(
24646                request,
24647                |_| Some(agent("claude")),
24648                |_| None,
24649            ));
24650        }
24651
24652        assert!(request_requires_open_provider_ids_with(
24653            &NodeRequest::Spawn {
24654                workspace_id: WorkspaceId::new("primary").unwrap(),
24655                provider: agent("third-party-agent"),
24656                mode: SessionMode::Pty,
24657                terminal_size: size,
24658                initial_prompt: None,
24659            },
24660            |_| None,
24661            |_| None,
24662        ));
24663        assert!(!request_requires_open_provider_ids_with(
24664            &NodeRequest::Snapshot,
24665            |_| None,
24666            |_| None,
24667        ));
24668        assert!(request_requires_open_provider_ids_with(
24669            &NodeRequest::ForgetContextPack {
24670                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24671            },
24672            |_| None,
24673            |_| None,
24674        ));
24675    }
24676
24677    #[test]
24678    fn legacy_projection_filters_open_record_replies_and_event_history() {
24679        let catalog = active_registry().unwrap();
24680        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24681        let workspace = WorkspaceConfig::new(
24682            WorkspaceId::new("primary").unwrap(),
24683            std::env::current_dir().unwrap(),
24684        )
24685        .unwrap();
24686        let shared = NodeShared::new(
24687            handle,
24688            "fixture-token".to_owned(),
24689            NodeId::new("node-1").unwrap(),
24690            vec![workspace],
24691            vec![agent("claude"), agent("grok")],
24692        );
24693        let legacy = record("claude", "legacy-record");
24694        let open = record("grok", "open-record");
24695        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24696            record: legacy.clone(),
24697        });
24698        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24699            record: open.clone(),
24700        });
24701        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24702            record_id: legacy.record_id.clone(),
24703        });
24704        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24705            record_id: open.record_id.clone(),
24706        });
24707        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24708        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24709        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24710        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24711
24712        let mut record_reply = ResponseEnvelope {
24713            request_id: 1,
24714            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24715        };
24716        project_response_legacy_provider_ids(&shared, &mut record_reply);
24717        assert!(matches!(
24718            record_reply.result,
24719            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24720        ));
24721
24722        let mut resync = ResponseEnvelope {
24723            request_id: 2,
24724            result: Ok(NodeResponse::Resync {
24725                event_sequence: open_removed.sequence,
24726                oldest_available_sequence: 1,
24727                snapshot: shared.snapshot(),
24728                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24729            }),
24730        };
24731        project_response_legacy_provider_ids(&shared, &mut resync);
24732        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24733            panic!("legacy projection changed the resync response kind");
24734        };
24735        assert!(snapshot.session_records.iter().all(|item| {
24736            provider_id_is_legacy(&item.provider)
24737        }));
24738        assert_eq!(events.len(), 2);
24739        assert!(events.iter().all(|event| matches!(
24740            &event.event,
24741            NodeEvent::SessionRecordUpserted { record }
24742                if provider_id_is_legacy(&record.provider)
24743        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24744    }
24745
24746    #[test]
24747    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24748        let catalog = active_registry().unwrap();
24749        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24750        let workspace_id = WorkspaceId::new("primary").unwrap();
24751        let workspace = WorkspaceConfig::new(
24752            workspace_id.clone(),
24753            std::env::current_dir().unwrap(),
24754        )
24755        .unwrap();
24756        let shared = NodeShared::new(
24757            handle,
24758            "fixture-token".to_owned(),
24759            NodeId::new("node-1").unwrap(),
24760            vec![workspace],
24761            vec![agent("claude")],
24762        );
24763        for index in 0..CONTROL_SESSIONS_MAX {
24764            shared.bind_session(
24765                AgentInstanceId((index + 1) as u64),
24766                workspace_id.clone(),
24767                SessionGeneration(1),
24768            );
24769        }
24770        let error = shared.ensure_binding_capacity().unwrap_err();
24771        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24772    }
24773
24774    #[tokio::test]
24775    async fn provider_spawn_admission_rejects_before_session_mutation() {
24776        let catalog = active_registry().unwrap();
24777        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24778        let workspace = WorkspaceConfig::new(
24779            WorkspaceId::new("primary").unwrap(),
24780            std::env::current_dir().unwrap(),
24781        )
24782        .unwrap();
24783        let mut shared = NodeShared::new(
24784            handle,
24785            "fixture-token".to_owned(),
24786            NodeId::new("node-provider-admission").unwrap(),
24787            vec![workspace],
24788            vec![agent("claude")],
24789        );
24790        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24791            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24792        ])
24793        .unwrap();
24794        let terminal_size = gate4agent_types::TerminalSize {
24795            rows: 24,
24796            columns: 80,
24797        };
24798
24799        let unavailable = shared
24800            .spawn_session(
24801                WorkspaceId::new("primary").unwrap(),
24802                agent("claude"),
24803                SessionMode::Pty,
24804                terminal_size,
24805                None,
24806            )
24807            .await
24808            .unwrap_err();
24809        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24810        assert_eq!(unavailable.message, "backend-operation-failed");
24811
24812        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24813            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24814                agent("claude"),
24815                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24816            ),
24817        ])
24818        .unwrap();
24819        let semantic = shared
24820            .spawn_session(
24821                WorkspaceId::new("primary").unwrap(),
24822                agent("claude"),
24823                SessionMode::Inline,
24824                terminal_size,
24825                None,
24826            )
24827            .await
24828            .unwrap_err();
24829        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24830        assert_eq!(semantic.message, "unsupported-capability");
24831
24832        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24833        assert!(shared
24834            .session_bindings
24835            .lock()
24836            .unwrap_or_else(|poisoned| poisoned.into_inner())
24837            .is_empty());
24838        assert!(shared
24839            .session_records
24840            .lock()
24841            .unwrap_or_else(|poisoned| poisoned.into_inner())
24842            .records
24843            .is_empty());
24844        assert!(shared.handle.snapshot().sessions.is_empty());
24845    }
24846
24847    #[tokio::test]
24848    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24849        let catalog = active_registry().unwrap();
24850        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24851        let workspace_id = WorkspaceId::new("primary").unwrap();
24852        let workspace = WorkspaceConfig::new(
24853            workspace_id.clone(),
24854            std::env::current_dir().unwrap(),
24855        )
24856        .unwrap();
24857        let shared = NodeShared::new(
24858            handle,
24859            "fixture-token".to_owned(),
24860            NodeId::new("node-raw-spawn-record").unwrap(),
24861            vec![workspace],
24862            vec![agent("claude")],
24863        );
24864        let address = SessionAddress {
24865            workspace_id,
24866            session: SessionKey {
24867                instance_id: AgentInstanceId(71),
24868                generation: SessionGeneration::default(),
24869            },
24870        };
24871
24872        let record_id = shared
24873            .bind_spawn_session(
24874                &address,
24875                agent("claude"),
24876                SessionMode::Pty,
24877                ProviderRuntimePolicy::raw_pty(),
24878                SpawnRecordPolicy::Always,
24879                None,
24880            )
24881            .unwrap()
24882            .unwrap();
24883        shared
24884            .handle
24885            .dispatch(shared.prepare_command(ControlCommand::Register {
24886                instance_id: address.session.instance_id,
24887                agent_id: agent("claude"),
24888                transport: TransportKind::Pty,
24889            }))
24890            .unwrap();
24891        runtime.tick().await;
24892
24893        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24894            session.instance_id == address.session.instance_id
24895                && session.generation == address.session.generation
24896        }));
24897        let record = shared.record(&record_id).unwrap();
24898        assert_eq!(record.display_name, "claude #1");
24899        assert_eq!(record.state, ManagedSessionState::Live);
24900        assert_eq!(record.active_session.as_ref(), Some(&address));
24901        assert!(record.provider_session.is_none());
24902        let binding_record_id = shared
24903            .session_bindings
24904            .lock()
24905            .unwrap_or_else(|poisoned| poisoned.into_inner())
24906            .get(&address.session.instance_id)
24907            .and_then(|binding| binding.record_id.clone());
24908        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24909
24910        let renamed = shared
24911            .rename_session_record(&record_id, "raw review".to_owned())
24912            .unwrap();
24913        assert_eq!(renamed.record_id, record_id);
24914        assert_eq!(renamed.display_name, "raw review");
24915        shared.reconcile_managed_record(
24916            &record_id,
24917            &address,
24918            &ControlEvent {
24919                sequence: 1,
24920                command_id: None,
24921                instance_id: address.session.instance_id,
24922                generation: address.session.generation,
24923                event: ControlEventKind::Running { process_id: None },
24924            },
24925            false,
24926            false,
24927        );
24928        let running = shared.record(&record_id).unwrap();
24929        assert_eq!(running.state, ManagedSessionState::Live);
24930        assert_eq!(running.active_session.as_ref(), Some(&address));
24931        assert_eq!(running.display_name, "raw review");
24932
24933        shared.reconcile_managed_record(
24934            &record_id,
24935            &address,
24936            &ControlEvent {
24937                sequence: 2,
24938                command_id: None,
24939                instance_id: address.session.instance_id,
24940                generation: address.session.generation,
24941                event: ControlEventKind::Exited {
24942                    exit_code: Some(0),
24943                    forced: false,
24944                },
24945            },
24946            false,
24947            false,
24948        );
24949        let detached = shared.record(&record_id).unwrap();
24950        assert_eq!(detached.state, ManagedSessionState::Unavailable);
24951        assert!(detached.active_session.is_none());
24952        assert_eq!(detached.display_name, "raw review");
24953        let renamed_detached = shared
24954            .rename_session_record(&record_id, "raw review detached".to_owned())
24955            .unwrap();
24956        assert_eq!(renamed_detached.record_id, record_id);
24957        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24958        assert!(renamed_detached.active_session.is_none());
24959        assert_eq!(renamed_detached.display_name, "raw review detached");
24960        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24961    }
24962
24963    #[test]
24964    fn legacy_raw_spawn_binding_remains_untracked() {
24965        let catalog = active_registry().unwrap();
24966        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24967        let workspace_id = WorkspaceId::new("primary").unwrap();
24968        let workspace = WorkspaceConfig::new(
24969            workspace_id.clone(),
24970            std::env::current_dir().unwrap(),
24971        )
24972        .unwrap();
24973        let shared = NodeShared::new(
24974            handle,
24975            "fixture-token".to_owned(),
24976            NodeId::new("node-legacy-raw-spawn").unwrap(),
24977            vec![workspace],
24978            vec![agent("claude")],
24979        );
24980        let address = SessionAddress {
24981            workspace_id,
24982            session: SessionKey {
24983                instance_id: AgentInstanceId(72),
24984                generation: SessionGeneration::default(),
24985            },
24986        };
24987
24988        let record_id = shared
24989            .bind_spawn_session(
24990                &address,
24991                agent("claude"),
24992                SessionMode::Pty,
24993                ProviderRuntimePolicy::raw_pty(),
24994                SpawnRecordPolicy::ProviderIdentityOnly,
24995                None,
24996            )
24997            .unwrap();
24998
24999        assert!(record_id.is_none());
25000        let binding_record_id = shared
25001            .session_bindings
25002            .lock()
25003            .unwrap_or_else(|poisoned| poisoned.into_inner())
25004            .get(&address.session.instance_id)
25005            .and_then(|binding| binding.record_id.clone());
25006        assert!(binding_record_id.is_none());
25007        assert!(shared.snapshot().session_records.is_empty());
25008    }
25009
25010    #[tokio::test]
25011    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25012        let catalog = active_registry().unwrap();
25013        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25014        let workspace_id = WorkspaceId::new("primary").unwrap();
25015        let workspace = WorkspaceConfig::new(
25016            workspace_id.clone(),
25017            std::env::current_dir().unwrap(),
25018        )
25019        .unwrap();
25020        let mut shared = NodeShared::new(
25021            handle,
25022            "fixture-token".to_owned(),
25023            NodeId::new("node-raw-semantic-gate").unwrap(),
25024            vec![workspace],
25025            vec![agent("claude")],
25026        );
25027        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25028            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25029        ])
25030        .unwrap();
25031        shared
25032            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25033            .unwrap();
25034        let address = SessionAddress {
25035            workspace_id,
25036            session: SessionKey {
25037                instance_id: AgentInstanceId(72),
25038                generation: SessionGeneration::default(),
25039            },
25040        };
25041        shared.bind_session_with_policy(
25042            &address,
25043            ProviderRuntimePolicy::raw_pty(),
25044            None,
25045        );
25046        shared
25047            .handle
25048            .dispatch(shared.prepare_command(ControlCommand::Register {
25049                instance_id: address.session.instance_id,
25050                agent_id: agent("claude"),
25051                transport: TransportKind::Pty,
25052            }))
25053            .unwrap();
25054        runtime.tick().await;
25055        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25056        let revision_before = shared.handle.snapshot().revision;
25057        let history_sequence_before = shared.current_sequence();
25058        let terminal_size = gate4agent_types::TerminalSize {
25059            rows: 24,
25060            columns: 80,
25061        };
25062
25063        for request in [
25064            NodeRequest::Prompt {
25065                session: address.clone(),
25066                text: "prompt".to_owned(),
25067            },
25068            NodeRequest::Paste {
25069                session: address.clone(),
25070                text: "paste".to_owned(),
25071            },
25072            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25073            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25074            // (promptless) resume is a raw PTY relaunch and IS admitted under
25075            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25076            // in `provider_runtime.rs`; it is deliberately not exercised here.
25077            NodeRequest::Resume {
25078                session: address.clone(),
25079                terminal_size,
25080                initial_prompt: Some("resume".to_owned()),
25081            },
25082        ] {
25083            let error = process_request_inner(
25084                &shared,
25085                77,
25086                ClientRole::Operator,
25087                request,
25088            )
25089            .await
25090            .unwrap_err();
25091            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25092        }
25093
25094        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25095        assert_eq!(shared.handle.snapshot().revision, revision_before);
25096        assert_eq!(shared.current_sequence(), history_sequence_before);
25097        let bindings = shared
25098            .session_bindings
25099            .lock()
25100            .unwrap_or_else(|poisoned| poisoned.into_inner());
25101        let binding = bindings.get(&address.session.instance_id).unwrap();
25102        assert_eq!(binding.generation, address.session.generation);
25103        assert!(binding.pending_resume.is_none());
25104        assert!(binding.record_id.is_none());
25105    }
25106
25107    #[tokio::test]
25108    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25109        let catalog = active_registry().unwrap();
25110        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25111        let primary = WorkspaceConfig::new(
25112            WorkspaceId::new("primary").unwrap(),
25113            std::env::current_dir().unwrap(),
25114        )
25115        .unwrap();
25116        let shared = NodeShared::new(
25117            handle,
25118            "fixture-token".to_owned(),
25119            NodeId::new("node-1").unwrap(),
25120            vec![primary],
25121            vec![agent("claude")],
25122        );
25123        shared
25124            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25125            .unwrap();
25126        let before = shared.snapshot();
25127        let error = process_request_inner(
25128            &shared,
25129            77,
25130            ClientRole::Operator,
25131            NodeRequest::RegisterWorkspace {
25132                workspace_id: WorkspaceId::new("foreign").unwrap(),
25133                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25134            },
25135        )
25136        .await
25137        .unwrap_err();
25138        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25139        assert_eq!(error.message, "invalid-request");
25140        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25141    }
25142
25143    #[tokio::test]
25144    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25145        let catalog = active_registry().unwrap();
25146        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25147        let primary_id = WorkspaceId::new("primary").unwrap();
25148        let primary = WorkspaceConfig::new(
25149            primary_id.clone(),
25150            std::env::current_dir().unwrap(),
25151        )
25152        .unwrap();
25153        let shared = NodeShared::new(
25154            handle,
25155            "fixture-token".to_owned(),
25156            NodeId::new("node-1").unwrap(),
25157            vec![primary.clone()],
25158            vec![agent("claude")],
25159        );
25160        let secondary_id = WorkspaceId::new("secondary").unwrap();
25161        let secondary = shared
25162            .register_workspace(
25163                secondary_id.clone(),
25164                std::env::temp_dir().to_string_lossy().into_owned(),
25165            )
25166            .await
25167            .unwrap();
25168        assert_eq!(secondary.workspace_id, secondary_id);
25169        assert!(secondary.sessions.is_empty());
25170        assert_eq!(shared.snapshot().workspaces.len(), 2);
25171
25172        let duplicate_id = shared
25173            .register_workspace(
25174                secondary_id.clone(),
25175                primary.canonical_root().to_owned(),
25176            )
25177            .await
25178            .unwrap_err();
25179        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25180        let duplicate_root = shared
25181            .register_workspace(
25182                WorkspaceId::new("duplicate-root").unwrap(),
25183                windows_path_text(&secondary.canonical_root).to_owned(),
25184            )
25185            .await
25186            .unwrap_err();
25187        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25188
25189        let address = SessionAddress {
25190            workspace_id: secondary_id.clone(),
25191            session: SessionKey {
25192                instance_id: AgentInstanceId(41),
25193                generation: SessionGeneration(1),
25194            },
25195        };
25196        shared.bind_session(
25197            address.session.instance_id,
25198            secondary_id.clone(),
25199            address.session.generation,
25200        );
25201        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25202        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25203        shared.remove_binding(&address);
25204        shared.unregister_workspace(&secondary_id).unwrap();
25205        assert_eq!(shared.snapshot().workspaces.len(), 1);
25206
25207        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25208        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25209        let history = shared
25210            .history
25211            .lock()
25212            .unwrap_or_else(|poisoned| poisoned.into_inner());
25213        assert!(matches!(
25214            &history.events[0],
25215            NodeEventEnvelope {
25216                sequence: 1,
25217                event: NodeEvent::WorkspaceAdded { workspace },
25218            } if workspace.workspace_id == secondary_id
25219        ));
25220        assert!(matches!(
25221            &history.events[1],
25222            NodeEventEnvelope {
25223                sequence: 2,
25224                event: NodeEvent::WorkspaceRemoved { workspace_id },
25225            } if workspace_id == &secondary_id
25226        ));
25227    }
25228
25229    #[tokio::test]
25230    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25231        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25232        std::fs::create_dir_all(&secondary_root).unwrap();
25233        let catalog = active_registry().unwrap();
25234        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25235        let primary_id = WorkspaceId::new("primary").unwrap();
25236        let secondary_id = WorkspaceId::new("secondary").unwrap();
25237        let shared = NodeShared::new(
25238            handle,
25239            "fixture-token".to_owned(),
25240            NodeId::new("node-session-reference-index").unwrap(),
25241            vec![
25242                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25243                    .unwrap(),
25244                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25245            ],
25246            vec![agent("claude"), agent("codex")],
25247        );
25248        let identity = ProviderSessionIdentity {
25249            key: gate4agent_types::ProviderSessionKey::SessionId,
25250            id: "provider-session-42".to_owned(),
25251            transcript_path: None,
25252        };
25253        let index_request = |workspace_id: WorkspaceId,
25254                             provider: AgentId,
25255                             identity: ProviderSessionIdentity,
25256                             display_name: &str| {
25257            NodeRequest::IndexProviderSession {
25258                workspace_id,
25259                provider,
25260                identity,
25261                display_name: display_name.to_owned(),
25262            }
25263        };
25264        let request = index_request(
25265            primary_id.clone(),
25266            agent("claude"),
25267            identity.clone(),
25268            "release shepherd",
25269        );
25270        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25271            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25272        }));
25273        let observer = process_request_inner(
25274            &shared,
25275            77,
25276            ClientRole::Observer,
25277            request.clone(),
25278        )
25279        .await
25280        .unwrap_err();
25281        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25282        let uncontrolled = process_request_inner(
25283            &shared,
25284            77,
25285            ClientRole::Operator,
25286            request.clone(),
25287        )
25288        .await
25289        .unwrap_err();
25290        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25291        shared
25292            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25293            .unwrap();
25294
25295        let indexed = process_request_inner(
25296            &shared,
25297            77,
25298            ClientRole::Operator,
25299            request.clone(),
25300        )
25301        .await
25302        .unwrap();
25303        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25304            panic!("provider session reference index returned another response");
25305        };
25306        assert_eq!(record.state, ManagedSessionState::Dormant);
25307        assert_eq!(record.mode, SessionMode::Pty);
25308        assert_eq!(record.workspace_id, primary_id);
25309        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25310        assert!(record.active_session.is_none());
25311        assert!(record.environment_profile.is_none());
25312        assert!(record.bundle.is_none());
25313        assert!(record.context.is_none());
25314
25315        let repeated = process_request_inner(
25316            &shared,
25317            77,
25318            ClientRole::Operator,
25319            index_request(
25320                primary_id.clone(),
25321                agent("claude"),
25322                identity.clone(),
25323                "ignored replacement",
25324            ),
25325        )
25326        .await
25327        .unwrap();
25328        assert!(matches!(
25329            repeated,
25330            NodeResponse::ProviderSessionIndexed { record: ref existing }
25331                if existing.record_id == record.record_id
25332                    && existing.display_name == "release shepherd"
25333        ));
25334        let conflicting_workspace = process_request_inner(
25335            &shared,
25336            77,
25337            ClientRole::Operator,
25338            index_request(
25339                secondary_id,
25340                agent("claude"),
25341                identity.clone(),
25342                "release shepherd",
25343            ),
25344        )
25345        .await
25346        .unwrap_err();
25347        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25348        let conflicting_provider = process_request_inner(
25349            &shared,
25350            77,
25351            ClientRole::Operator,
25352            index_request(
25353                primary_id.clone(),
25354                agent("codex"),
25355                identity.clone(),
25356                "release shepherd",
25357            ),
25358        )
25359        .await
25360        .unwrap_err();
25361        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25362        let transcript_rejected = process_request_inner(
25363            &shared,
25364            77,
25365            ClientRole::Operator,
25366            index_request(
25367                primary_id,
25368                agent("claude"),
25369                ProviderSessionIdentity {
25370                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25371                    ..identity
25372                },
25373                "release shepherd",
25374            ),
25375        )
25376        .await
25377        .unwrap_err();
25378        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25379        let upserts = shared
25380            .history
25381            .lock()
25382            .unwrap_or_else(|poisoned| poisoned.into_inner())
25383            .events
25384            .iter()
25385            .filter(|event| matches!(
25386                &event.event,
25387                NodeEvent::SessionRecordUpserted { record: published }
25388                    if published.record_id == record.record_id
25389            ))
25390            .count();
25391        assert_eq!(upserts, 1);
25392        std::fs::remove_dir_all(secondary_root).unwrap();
25393    }
25394
25395    #[tokio::test]
25396    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25397        let fixture_root = temporary_workspace_root("standalone-independent");
25398        let target = fixture_root.join("independent");
25399        std::fs::create_dir_all(&fixture_root).unwrap();
25400        let catalog = active_registry().unwrap();
25401        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25402        let selected = WorkspaceConfig::new(
25403            WorkspaceId::new("primary").unwrap(),
25404            std::env::current_dir().unwrap(),
25405        )
25406        .unwrap();
25407        let selected_root = selected.canonical_root().to_owned();
25408        let shared = NodeShared::new(
25409            handle,
25410            "fixture-token".to_owned(),
25411            NodeId::new("node-standalone").unwrap(),
25412            vec![selected],
25413            vec![agent("claude")],
25414        );
25415        let workspace_id = WorkspaceId::new("independent").unwrap();
25416        let request = NodeRequest::CreateStandaloneWorkspace {
25417            workspace_id: workspace_id.clone(),
25418            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25419            initial_branch: Some("main".to_owned()),
25420        };
25421        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25422            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25423        }));
25424        assert!(!request_uses_unnegotiated_capability(
25425            &request,
25426            &baseline_capabilities().unwrap(),
25427        ));
25428        assert!(request_uses_unnegotiated_capability(&request, &[]));
25429
25430        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25431            .await
25432            .unwrap_err();
25433        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25434        assert!(!target.exists());
25435        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25436            .await
25437            .unwrap_err();
25438        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25439        assert!(!target.exists());
25440        shared
25441            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25442            .unwrap();
25443
25444        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25445            .await
25446            .unwrap();
25447        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25448            panic!("standalone workspace creation returned another response");
25449        };
25450        assert_eq!(workspace.workspace_id, workspace_id);
25451        assert!(target.join(".git").is_dir());
25452        let branch = run_git_bounded(
25453            target.to_str().unwrap(),
25454            &["branch", "--show-current"],
25455            GIT_OUTPUT_MAX_BYTES,
25456        )
25457        .await
25458        .unwrap();
25459        assert!(branch.success);
25460        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25461        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25462        assert_eq!(worktrees.len(), 1);
25463        let listed_root = WorkspaceConfig::new(
25464            WorkspaceId::new("listed-independent").unwrap(),
25465            &worktrees[0].path,
25466        )
25467        .unwrap();
25468        assert!(platform::roots_equal(
25469            listed_root.canonical_root(),
25470            windows_path_text(&workspace.canonical_root),
25471        ));
25472        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25473        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25474            snapshot.workspace_id == workspace_id
25475                && snapshot.canonical_root == workspace.canonical_root
25476        }));
25477        assert!(shared
25478            .history
25479            .lock()
25480            .unwrap_or_else(|poisoned| poisoned.into_inner())
25481            .events
25482            .iter()
25483            .any(|event| matches!(
25484                &event.event,
25485                NodeEvent::WorkspaceAdded { workspace }
25486                    if workspace.workspace_id == workspace_id
25487            )));
25488        std::fs::remove_dir_all(fixture_root).unwrap();
25489    }
25490
25491    #[tokio::test]
25492    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25493        let fixture_root = temporary_workspace_root("standalone-compensation");
25494        let created_target = fixture_root.join("created-target");
25495        let existing_target = fixture_root.join("existing-target");
25496        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25497        let invalid_state_path = invalid_state_parent.join("state.json");
25498        std::fs::create_dir_all(&existing_target).unwrap();
25499        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25500        let catalog = active_registry().unwrap();
25501        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25502        let primary = WorkspaceConfig::new(
25503            WorkspaceId::new("primary").unwrap(),
25504            std::env::current_dir().unwrap(),
25505        )
25506        .unwrap();
25507        let mut shared = NodeShared::new(
25508            handle,
25509            "fixture-token".to_owned(),
25510            NodeId::new("node-standalone-compensation").unwrap(),
25511            vec![primary],
25512            vec![agent("claude")],
25513        );
25514        shared.state_path = Some(invalid_state_path);
25515
25516        let created_recovery = shared
25517            .create_standalone_workspace(
25518                WorkspaceId::new("compensated").unwrap(),
25519                created_target.to_string_lossy().into_owned(),
25520                Some("main".to_owned()),
25521            )
25522            .await
25523            .unwrap_err();
25524        assert_eq!(
25525            created_recovery.code,
25526            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25527        );
25528        assert!(created_target.join(".git").is_dir());
25529        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25530            workspace.workspace_id.as_str() != "compensated"
25531        }));
25532
25533        let existing_recovery = shared
25534            .create_standalone_workspace(
25535                WorkspaceId::new("restored").unwrap(),
25536                existing_target.to_string_lossy().into_owned(),
25537                None,
25538            )
25539            .await
25540            .unwrap_err();
25541        assert_eq!(
25542            existing_recovery.code,
25543            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25544        );
25545        assert!(existing_target.is_dir());
25546        assert!(existing_target.join(".git").is_dir());
25547        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25548            workspace.workspace_id.as_str() != "restored"
25549        }));
25550
25551        let registered_empty = fixture_root.join("registered-empty");
25552        std::fs::create_dir(&registered_empty).unwrap();
25553        let catalog = active_registry().unwrap();
25554        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25555        let registered = WorkspaceConfig::new(
25556            WorkspaceId::new("registered-empty").unwrap(),
25557            &registered_empty,
25558        )
25559        .unwrap();
25560        let duplicate_shared = NodeShared::new(
25561            handle,
25562            "fixture-token".to_owned(),
25563            NodeId::new("node-standalone-duplicate-root").unwrap(),
25564            vec![registered],
25565            vec![agent("claude")],
25566        );
25567        let duplicate = duplicate_shared
25568            .create_standalone_workspace(
25569                WorkspaceId::new("duplicate-root").unwrap(),
25570                registered_empty.to_string_lossy().into_owned(),
25571                None,
25572            )
25573            .await
25574            .unwrap_err();
25575        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25576        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25577        std::fs::remove_dir_all(fixture_root).unwrap();
25578    }
25579
25580    #[test]
25581    fn managed_record_id_collision_preserves_the_existing_record() {
25582        let catalog = active_registry().unwrap();
25583        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25584        let workspace = WorkspaceConfig::new(
25585            WorkspaceId::new("primary").unwrap(),
25586            std::env::current_dir().unwrap(),
25587        )
25588        .unwrap();
25589        let shared = NodeShared::new(
25590            handle,
25591            "fixture-token".to_owned(),
25592            NodeId::new("node-1").unwrap(),
25593            vec![workspace.clone()],
25594            vec![agent("claude")],
25595        );
25596        let record_id = SessionRecordId::new("sr-collision").unwrap();
25597        let original = ManagedSessionRecord {
25598            record_id: record_id.clone(),
25599            display_name: "original".to_owned(),
25600            provider: agent("claude"),
25601            mode: SessionMode::Pty,
25602            state: ManagedSessionState::Dormant,
25603            workspace_id: workspace.workspace_id,
25604            canonical_root: opaque_windows_path(workspace.canonical_root),
25605            provider_session: None,
25606            active_session: None,
25607            environment_profile: None,
25608            bundle: None,
25609            context_id: None,
25610            context: None,
25611            exported_context: None,
25612            task_binding: None,
25613            created_at_unix_ms: 1,
25614            updated_at_unix_ms: 1,
25615            last_error: None,
25616        };
25617        shared.insert_record(original.clone()).unwrap();
25618        let mut collision = original.clone();
25619        collision.display_name = "replacement".to_owned();
25620        let error = shared.insert_record(collision).unwrap_err();
25621        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25622        assert_eq!(shared.record(&record_id).unwrap(), original);
25623    }
25624
25625    #[test]
25626    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25627        let catalog = active_registry().unwrap();
25628        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25629        let workspace = WorkspaceConfig::new(
25630            WorkspaceId::new("primary").unwrap(),
25631            std::env::current_dir().unwrap(),
25632        )
25633        .unwrap();
25634        let shared = NodeShared::new(
25635            handle,
25636            "fixture-token".to_owned(),
25637            NodeId::new("node-1").unwrap(),
25638            vec![workspace.clone()],
25639            vec![agent("claude")],
25640        );
25641        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25642        shared
25643            .insert_record(ManagedSessionRecord {
25644                record_id: record_id.clone(),
25645                display_name: "runtime error".to_owned(),
25646                provider: agent("claude"),
25647                mode: SessionMode::Pty,
25648                state: ManagedSessionState::IdentityPending,
25649                workspace_id: workspace.workspace_id,
25650                canonical_root: opaque_windows_path(workspace.canonical_root),
25651                provider_session: None,
25652                active_session: None,
25653                environment_profile: None,
25654                bundle: None,
25655                context_id: None,
25656                context: None,
25657                exported_context: None,
25658                task_binding: None,
25659                created_at_unix_ms: 1,
25660                updated_at_unix_ms: 1,
25661                last_error: None,
25662            })
25663            .unwrap();
25664
25665        shared
25666            .mark_record_error(
25667                &record_id,
25668                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25669            )
25670            .unwrap();
25671        let record = shared.record(&record_id).unwrap();
25672        assert_eq!(record.state, ManagedSessionState::Unavailable);
25673        assert_eq!(
25674            record.last_error.as_deref(),
25675            Some("provider-runtime-failed"),
25676        );
25677        let snapshot = shared.snapshot();
25678        assert_eq!(snapshot.session_records, vec![record]);
25679    }
25680
25681    /// Regression coverage for the "operator sees `deadline` instead of the
25682    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25683    /// able to learn a command's asynchronous kernel rejection (here,
25684    /// specifically classified as `UnsupportedTransport`) exactly once, and
25685    /// must not still find it after that -- proving the fast path this
25686    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25687    /// loop) drains the reason instead of leaving it to be rediscovered (or
25688    /// missed) on a later poll.
25689    #[test]
25690    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25691        let catalog = active_registry().unwrap();
25692        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25693        let workspace = WorkspaceConfig::new(
25694            WorkspaceId::new("primary").unwrap(),
25695            std::env::current_dir().unwrap(),
25696        )
25697        .unwrap();
25698        let shared = NodeShared::new(
25699            handle,
25700            "fixture-token".to_owned(),
25701            NodeId::new("node-1").unwrap(),
25702            vec![workspace],
25703            vec![agent("claude")],
25704        );
25705
25706        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25707
25708        shared.record_rejected_command(
25709            CommandId(1),
25710            "agent 'claude' does not support transport Acp".to_owned(),
25711            true,
25712        );
25713        // An unrelated command_id must not see another command's reason.
25714        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25715
25716        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25717        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25718        assert!(reason.unsupported_transport);
25719
25720        // Consumed: a second read for the same command_id finds nothing.
25721        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25722
25723        // A rejection unrelated to transport support keeps its own
25724        // classification distinct, so the reader can choose the generic
25725        // `BackendOperationFailed` code instead of misreporting
25726        // `UnsupportedTransport`.
25727        shared.record_rejected_command(
25728            CommandId(3),
25729            "backend busy".to_owned(),
25730            false,
25731        );
25732        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25733        assert!(!unclassified.unsupported_transport);
25734    }
25735
25736    #[test]
25737    fn provider_identity_update_is_ignored_without_exact_session_policy() {
25738        let catalog = active_registry().unwrap();
25739        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25740        let workspace = WorkspaceConfig::new(
25741            WorkspaceId::new("primary").unwrap(),
25742            std::env::current_dir().unwrap(),
25743        )
25744        .unwrap();
25745        let shared = NodeShared::new(
25746            handle,
25747            "fixture-token".to_owned(),
25748            NodeId::new("node-identity-policy").unwrap(),
25749            vec![workspace.clone()],
25750            vec![agent("claude")],
25751        );
25752        let address = SessionAddress {
25753            workspace_id: workspace.workspace_id.clone(),
25754            session: SessionKey {
25755                instance_id: AgentInstanceId(90),
25756                generation: SessionGeneration(1),
25757            },
25758        };
25759        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25760        shared
25761            .insert_record(ManagedSessionRecord {
25762                record_id: record_id.clone(),
25763                display_name: "policy denied".to_owned(),
25764                provider: agent("claude"),
25765                mode: SessionMode::Pty,
25766                state: ManagedSessionState::IdentityPending,
25767                workspace_id: workspace.workspace_id,
25768                canonical_root: opaque_windows_path(workspace.canonical_root),
25769                provider_session: None,
25770                active_session: Some(address.clone()),
25771                environment_profile: None,
25772                bundle: None,
25773                context_id: None,
25774                context: None,
25775                exported_context: None,
25776                task_binding: None,
25777                created_at_unix_ms: 1,
25778                updated_at_unix_ms: 1,
25779                last_error: None,
25780            })
25781            .unwrap();
25782        let event = ControlEvent {
25783            sequence: 1,
25784            command_id: None,
25785            instance_id: address.session.instance_id,
25786            generation: address.session.generation,
25787            event: ControlEventKind::ProviderEvent {
25788                sequence: 1,
25789                source: gate4agent_types::ProviderSource {
25790                    family: AdapterFamily::PtySemantic,
25791                    binding: AdapterBinding::new(
25792                        gate4agent_types::AdapterId::new("claude").unwrap(),
25793                        "fixture/v1",
25794                        gate4agent_types::AdapterVerification::SyntheticFixture,
25795                    )
25796                    .unwrap(),
25797                },
25798                source_sequence: 1,
25799                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25800                    identity: gate4agent_types::ProviderSessionIdentity {
25801                        key: gate4agent_types::ProviderSessionKey::SessionId,
25802                        id: "provider-session".to_owned(),
25803                        transcript_path: None,
25804                    },
25805                },
25806            },
25807        };
25808
25809        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25810
25811        let record = shared.record(&record_id).unwrap();
25812        assert!(record.provider_session.is_none());
25813        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25814        assert_eq!(record.active_session.as_ref(), Some(&address));
25815    }
25816
25817    #[test]
25818    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25819        let secondary_root = temporary_workspace_root("identity-scope");
25820        std::fs::create_dir_all(&secondary_root).unwrap();
25821        let catalog = active_registry().unwrap();
25822        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25823        let primary = WorkspaceConfig::new(
25824            WorkspaceId::new("primary").unwrap(),
25825            std::env::current_dir().unwrap(),
25826        )
25827        .unwrap();
25828        let secondary = WorkspaceConfig::new(
25829            WorkspaceId::new("secondary").unwrap(),
25830            &secondary_root,
25831        )
25832        .unwrap();
25833        let shared = NodeShared::new(
25834            handle,
25835            "fixture-token".to_owned(),
25836            NodeId::new("node-1").unwrap(),
25837            vec![primary.clone(), secondary.clone()],
25838            vec![agent("claude")],
25839        );
25840        let identity = gate4agent_types::ProviderSessionIdentity {
25841            key: gate4agent_types::ProviderSessionKey::SessionId,
25842            id: "provider-session".to_owned(),
25843            transcript_path: None,
25844        };
25845        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25846        shared
25847            .insert_record(ManagedSessionRecord {
25848                record_id: dormant_id.clone(),
25849                display_name: "dormant".to_owned(),
25850                provider: agent("claude"),
25851                mode: SessionMode::Pty,
25852                state: ManagedSessionState::Dormant,
25853                workspace_id: primary.workspace_id,
25854                canonical_root: opaque_windows_path(primary.canonical_root),
25855                provider_session: Some(identity.clone()),
25856                active_session: None,
25857                environment_profile: None,
25858                bundle: None,
25859                context_id: None,
25860                context: None,
25861                exported_context: None,
25862                task_binding: None,
25863                created_at_unix_ms: 1,
25864                updated_at_unix_ms: 1,
25865                last_error: None,
25866            })
25867            .unwrap();
25868        let address = SessionAddress {
25869            workspace_id: secondary.workspace_id.clone(),
25870            session: SessionKey {
25871                instance_id: AgentInstanceId(91),
25872                generation: SessionGeneration(1),
25873            },
25874        };
25875        let current_id = SessionRecordId::new("sr-current").unwrap();
25876        shared
25877            .insert_record(ManagedSessionRecord {
25878                record_id: current_id.clone(),
25879                display_name: "current".to_owned(),
25880                provider: agent("claude"),
25881                mode: SessionMode::Pty,
25882                state: ManagedSessionState::IdentityPending,
25883                workspace_id: secondary.workspace_id,
25884                canonical_root: opaque_windows_path(secondary.canonical_root),
25885                provider_session: None,
25886                active_session: Some(address.clone()),
25887                environment_profile: None,
25888                bundle: None,
25889                context_id: None,
25890                context: None,
25891                exported_context: None,
25892                task_binding: None,
25893                created_at_unix_ms: 2,
25894                updated_at_unix_ms: 2,
25895                last_error: None,
25896            })
25897            .unwrap();
25898        shared.bind_managed_session(
25899            &address,
25900            current_id.clone(),
25901            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25902            None,
25903        );
25904        let event = ControlEvent {
25905            sequence: 1,
25906            command_id: None,
25907            instance_id: address.session.instance_id,
25908            generation: address.session.generation,
25909            event: ControlEventKind::ProviderEvent {
25910                sequence: 1,
25911                source: gate4agent_types::ProviderSource {
25912                    family: gate4agent_types::AdapterFamily::PtySemantic,
25913                    binding: gate4agent_types::AdapterBinding::new(
25914                        gate4agent_types::AdapterId::new("claude").unwrap(),
25915                        "fixture/v1",
25916                        gate4agent_types::AdapterVerification::SyntheticFixture,
25917                    )
25918                    .unwrap(),
25919                },
25920                source_sequence: 1,
25921                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25922                    identity,
25923                },
25924            },
25925        };
25926        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
25927
25928        let binding = shared
25929            .session_bindings
25930            .lock()
25931            .unwrap_or_else(|poisoned| poisoned.into_inner())
25932            .get(&address.session.instance_id)
25933            .cloned()
25934            .unwrap();
25935        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
25936        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25937        let current = shared.record(&current_id).unwrap();
25938        assert_eq!(current.state, ManagedSessionState::Unavailable);
25939        assert_eq!(current.active_session.as_ref(), Some(&address));
25940        assert_eq!(
25941            current.last_error.as_deref(),
25942            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25943        );
25944        std::fs::remove_dir_all(secondary_root).unwrap();
25945    }
25946
25947    #[tokio::test]
25948    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25949        let root = temporary_workspace_root("semantic-provider-session");
25950        std::fs::create_dir_all(&root).unwrap();
25951        let state_path = root.join("state-v1.json");
25952        let catalog = active_registry().unwrap();
25953        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25954        let workspace = WorkspaceConfig::new(
25955            WorkspaceId::new("primary").unwrap(),
25956            &root,
25957        )
25958        .unwrap();
25959        let node_id = NodeId::new("node-semantic-identity").unwrap();
25960        let shared = NodeShared::new_with_incarnation(
25961            handle,
25962            "fixture-token".to_owned(),
25963            node_id.clone(),
25964            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25965            vec![workspace.clone()],
25966            vec![agent("claude")],
25967            ProviderRuntimeStatuses::default(),
25968            None,
25969            Vec::new(),
25970            Vec::new(),
25971            SpawnProfileRegistry::default(),
25972            None,
25973            None,
25974            None,
25975            Some(state_path.clone()),
25976            Vec::new(),
25977            Vec::new(),
25978            Vec::new(),
25979            Vec::new(),
25980            None,
25981            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25982        );
25983        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25984            key: gate4agent_types::ProviderSessionKey::SessionId,
25985            id: "provider-session".to_owned(),
25986            transcript_path: None,
25987        };
25988        let durable_id = SessionRecordId::new("sr-durable").unwrap();
25989        shared
25990            .insert_record(ManagedSessionRecord {
25991                record_id: durable_id.clone(),
25992                display_name: "durable".to_owned(),
25993                provider: agent("claude"),
25994                mode: SessionMode::Pty,
25995                state: ManagedSessionState::Dormant,
25996                workspace_id: workspace.workspace_id.clone(),
25997                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25998                provider_session: Some(persisted_identity.clone()),
25999                active_session: None,
26000                environment_profile: None,
26001                bundle: None,
26002                context_id: None,
26003                context: None,
26004                exported_context: None,
26005                task_binding: None,
26006                created_at_unix_ms: 1,
26007                updated_at_unix_ms: 1,
26008                last_error: None,
26009            })
26010            .unwrap();
26011        let address = SessionAddress {
26012            workspace_id: workspace.workspace_id.clone(),
26013            session: SessionKey {
26014                instance_id: AgentInstanceId(92),
26015                generation: SessionGeneration(2),
26016            },
26017        };
26018        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26019        shared
26020            .insert_record(ManagedSessionRecord {
26021                record_id: pending_id.clone(),
26022                display_name: "pending".to_owned(),
26023                provider: agent("claude"),
26024                mode: SessionMode::Pty,
26025                state: ManagedSessionState::IdentityPending,
26026                workspace_id: workspace.workspace_id.clone(),
26027                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26028                provider_session: None,
26029                active_session: Some(address.clone()),
26030                environment_profile: None,
26031                bundle: None,
26032                context_id: None,
26033                context: None,
26034                exported_context: None,
26035                task_binding: None,
26036                created_at_unix_ms: 2,
26037                updated_at_unix_ms: 2,
26038                last_error: None,
26039            })
26040            .unwrap();
26041        shared.bind_managed_session(
26042            &address,
26043            pending_id.clone(),
26044            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26045            None,
26046        );
26047        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26048            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26049            ..persisted_identity
26050        };
26051        let event = ControlEvent {
26052            sequence: 1,
26053            command_id: None,
26054            instance_id: address.session.instance_id,
26055            generation: address.session.generation,
26056            event: ControlEventKind::ProviderEvent {
26057                sequence: 1,
26058                source: gate4agent_types::ProviderSource {
26059                    family: gate4agent_types::AdapterFamily::PtySemantic,
26060                    binding: gate4agent_types::AdapterBinding::new(
26061                        gate4agent_types::AdapterId::new("claude").unwrap(),
26062                        "fixture/v1",
26063                        gate4agent_types::AdapterVerification::SyntheticFixture,
26064                    )
26065                    .unwrap(),
26066                },
26067                source_sequence: 1,
26068                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26069                    identity: observed_identity.clone(),
26070                },
26071            },
26072        };
26073        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26074
26075        let snapshot = shared.snapshot();
26076        assert_eq!(snapshot.session_records.len(), 1);
26077        let live = &snapshot.session_records[0];
26078        assert_eq!(live.record_id, durable_id);
26079        assert_eq!(live.state, ManagedSessionState::Live);
26080        assert_eq!(live.active_session.as_ref(), Some(&address));
26081        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26082        let binding = shared
26083            .session_bindings
26084            .lock()
26085            .unwrap_or_else(|poisoned| poisoned.into_inner())
26086            .get(&address.session.instance_id)
26087            .cloned()
26088            .unwrap();
26089        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26090
26091        let mut duplicate = live.clone();
26092        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26093        duplicate.display_name = "duplicate".to_owned();
26094        duplicate.state = ManagedSessionState::Dormant;
26095        duplicate.active_session = None;
26096        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26097        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26098        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26099        assert_eq!(shared.snapshot().session_records.len(), 1);
26100
26101        let terminal_size = gate4agent_types::TerminalSize {
26102            rows: 24,
26103            columns: 80,
26104        };
26105        let (first_busy, second_busy) = tokio::join!(
26106            shared.resume_session_record(&durable_id, terminal_size, None),
26107            shared.resume_session_record(&durable_id, terminal_size, None),
26108        );
26109        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26110            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26111            assert_eq!(busy.message, "session-record-busy");
26112        }
26113
26114        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26115        assert_eq!(loaded.records.len(), 1);
26116        assert_eq!(loaded.records[0].record_id, durable_id);
26117        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26118        assert_eq!(
26119            loaded.records[0]
26120                .provider_session
26121                .as_ref()
26122                .unwrap()
26123                .transcript_path,
26124            None,
26125        );
26126        let catalog = active_registry().unwrap();
26127        let (reloaded_handle, _reloaded_runtime) =
26128            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26129        let reloaded = NodeShared::new_with_incarnation(
26130            reloaded_handle,
26131            "fixture-token".to_owned(),
26132            node_id,
26133            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26134            vec![workspace],
26135            vec![agent("claude")],
26136            ProviderRuntimeStatuses::default(),
26137            None,
26138            Vec::new(),
26139            Vec::new(),
26140            SpawnProfileRegistry::default(),
26141            None,
26142            None,
26143            None,
26144            None,
26145            loaded.records,
26146            loaded.managed_worktrees,
26147            loaded.managed_worktree_tombstones,
26148            loaded.materializations,
26149            loaded.warning,
26150            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26151        );
26152        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26153        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26154        std::fs::remove_dir_all(root).unwrap();
26155    }
26156
26157    #[tokio::test]
26158    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26159        let secondary_root = temporary_workspace_root("workspace-restore");
26160        let other_root = temporary_workspace_root("workspace-conflict");
26161        std::fs::create_dir_all(&secondary_root).unwrap();
26162        std::fs::create_dir_all(&other_root).unwrap();
26163        let catalog = active_registry().unwrap();
26164        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26165        let primary = WorkspaceConfig::new(
26166            WorkspaceId::new("primary").unwrap(),
26167            std::env::current_dir().unwrap(),
26168        )
26169        .unwrap();
26170        let secondary = WorkspaceConfig::new(
26171            WorkspaceId::new("secondary").unwrap(),
26172            &secondary_root,
26173        )
26174        .unwrap();
26175        let shared = NodeShared::new(
26176            handle,
26177            "fixture-token".to_owned(),
26178            NodeId::new("node-1").unwrap(),
26179            vec![primary, secondary.clone()],
26180            vec![agent("claude")],
26181        );
26182        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26183        shared
26184            .insert_record(ManagedSessionRecord {
26185                record_id: record_id.clone(),
26186                display_name: "workspace record".to_owned(),
26187                provider: agent("claude"),
26188                mode: SessionMode::Pty,
26189                state: ManagedSessionState::Dormant,
26190                workspace_id: secondary.workspace_id.clone(),
26191                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26192                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26193                    key: gate4agent_types::ProviderSessionKey::SessionId,
26194                    id: "provider-session".to_owned(),
26195                    transcript_path: None,
26196                }),
26197                active_session: None,
26198                environment_profile: None,
26199                bundle: None,
26200                context_id: None,
26201                context: None,
26202                exported_context: None,
26203                task_binding: None,
26204                created_at_unix_ms: 1,
26205                updated_at_unix_ms: 1,
26206                last_error: None,
26207            })
26208            .unwrap();
26209        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26210        let unavailable = shared.record(&record_id).unwrap();
26211        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26212        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26213
26214        let conflict = shared
26215            .register_workspace(
26216                secondary.workspace_id.clone(),
26217                other_root.to_string_lossy().into_owned(),
26218            )
26219            .await
26220            .unwrap_err();
26221        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26222        shared
26223            .register_workspace(
26224                secondary.workspace_id,
26225                secondary_root.to_string_lossy().into_owned(),
26226            )
26227            .await
26228            .unwrap();
26229        let restored = shared.record(&record_id).unwrap();
26230        assert_eq!(restored.state, ManagedSessionState::Dormant);
26231        assert!(restored.last_error.is_none());
26232        std::fs::remove_dir_all(secondary_root).unwrap();
26233        std::fs::remove_dir_all(other_root).unwrap();
26234    }
26235
26236    #[test]
26237    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26238        let error = validate_workspace_request_root(
26239            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26240        )
26241        .unwrap_err();
26242        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26243        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26244        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26245    }
26246
26247    #[test]
26248    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26249        let catalog = active_registry().unwrap();
26250        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26251        let workspace = WorkspaceConfig::new(
26252            WorkspaceId::new("primary").unwrap(),
26253            std::env::current_dir().unwrap(),
26254        )
26255        .unwrap();
26256        let shared = NodeShared::new(
26257            handle,
26258            "fixture-token".to_owned(),
26259            NodeId::new("node-1").unwrap(),
26260            vec![workspace],
26261            vec![agent("claude")],
26262        );
26263        let publishers = 4_u64;
26264        let events_per_publisher = 256_u64;
26265        let start = std::sync::Barrier::new(publishers as usize + 1);
26266        let finished = AtomicU64::new(0);
26267
26268        std::thread::scope(|scope| {
26269            for _ in 0..publishers {
26270                scope.spawn(|| {
26271                    start.wait();
26272                    for _ in 0..events_per_publisher {
26273                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26274                    }
26275                    finished.fetch_add(1, Ordering::Release);
26276                });
26277            }
26278            start.wait();
26279            while finished.load(Ordering::Acquire) != publishers {
26280                assert_resync_history_is_linearized(shared.resync(0));
26281                std::thread::yield_now();
26282            }
26283        });
26284        let response = shared.resync(0);
26285        assert_resync_history_is_linearized(response.clone());
26286        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26287            unreachable!("resync helper returned another response");
26288        };
26289        assert_eq!(event_sequence, publishers * events_per_publisher);
26290        assert_eq!(events.len() as u64, event_sequence);
26291    }
26292
26293    fn assert_resync_history_is_linearized(response: NodeResponse) {
26294        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26295            unreachable!("resync helper returned another response");
26296        };
26297        assert!(events.windows(2).all(|pair| {
26298            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26299        }));
26300        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26301    }
26302
26303    #[test]
26304    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26305        assert_eq!(
26306            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26307            r"C:\repo\worktree",
26308        );
26309        assert_eq!(
26310            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26311            r"\\server\share\repo",
26312        );
26313        assert!(validate_workspace_root(
26314            &WorkspaceId::new("network").unwrap(),
26315            r"\\server\share\repo",
26316        )
26317        .is_err());
26318        let workspace = WorkspaceConfig::new(
26319            WorkspaceId::new("primary").unwrap(),
26320            std::env::current_dir().unwrap(),
26321        )
26322        .unwrap();
26323        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26324    }
26325
26326    #[test]
26327    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26328        let catalog = active_registry().unwrap();
26329        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26330        let workspace_id = WorkspaceId::new("primary").unwrap();
26331        let workspace = WorkspaceConfig::new(
26332            workspace_id.clone(),
26333            std::env::current_dir().unwrap(),
26334        )
26335        .unwrap();
26336        let shared = NodeShared::new(
26337            handle,
26338            "fixture-token".to_owned(),
26339            NodeId::new("node-1").unwrap(),
26340            vec![workspace],
26341            vec![agent("claude")],
26342        );
26343        let instance_id = AgentInstanceId(17);
26344        let original = SessionAddress {
26345            workspace_id,
26346            session: SessionKey {
26347                instance_id,
26348                generation: SessionGeneration(3),
26349            },
26350        };
26351        shared.bind_session(
26352            instance_id,
26353            original.workspace_id.clone(),
26354            original.session.generation,
26355        );
26356        shared
26357            .arm_resume(
26358                &original,
26359                CommandId(41),
26360                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26361            )
26362            .unwrap();
26363
26364        shared.publish_control(ControlEvent {
26365            sequence: 1,
26366            command_id: None,
26367            instance_id,
26368            generation: SessionGeneration(4),
26369            event: ControlEventKind::Running { process_id: None },
26370        });
26371        assert_eq!(
26372            shared.address_for(instance_id, SessionGeneration(3)),
26373            Some(original.clone()),
26374        );
26375        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26376
26377        shared.publish_control(ControlEvent {
26378            sequence: 2,
26379            command_id: Some(CommandId(42)),
26380            instance_id,
26381            generation: SessionGeneration(3),
26382            event: ControlEventKind::CommandRejected {
26383                message: "unrelated command".to_owned(),
26384            },
26385        });
26386
26387        shared.publish_control(ControlEvent {
26388            sequence: 3,
26389            command_id: None,
26390            instance_id,
26391            generation: SessionGeneration(4),
26392            event: ControlEventKind::ResumeAuthorized {
26393                session: gate4agent_types::ResumeSessionSummary {
26394                    key: gate4agent_types::ProviderSessionKey::SessionId,
26395                    id: "fixture-session".to_owned(),
26396                },
26397            },
26398        });
26399        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26400        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26401        assert_eq!(rebound.workspace_id, original.workspace_id);
26402        let history = shared
26403            .history
26404            .lock()
26405            .unwrap_or_else(|poisoned| poisoned.into_inner());
26406        assert!(history.events.iter().any(|envelope| matches!(
26407            &envelope.event,
26408            NodeEvent::Control { address, event }
26409                if address == &rebound
26410                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26411        )));
26412    }
26413
26414    #[test]
26415    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26416        for agent in ["claude", "codex", "kimi"] {
26417            assert_eq!(
26418                prompt_framing(&AgentId::new(agent).unwrap()),
26419                PromptFraming::BracketedPaste
26420            );
26421        }
26422    }
26423
26424    fn generous_walk_deadline() -> Instant {
26425        Instant::now() + Duration::from_secs(30)
26426    }
26427
26428    #[test]
26429    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26430        let root = temporary_workspace_root("tree");
26431        std::fs::create_dir_all(root.join("src")).unwrap();
26432        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26433        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26434        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26435        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26436        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26437        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26438        // real repository's own build layout) that carries the Cache
26439        // Directory Tagging Specification marker cargo writes into every
26440        // `--target-dir` it creates: must be skipped even though its name
26441        // does not exactly match `target`.
26442        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26443        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26444        // A directory that merely *starts with* `target` but carries no
26445        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26446        // on the name alone, so this stays in the response.
26447        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26448        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26449
26450        let (entries, truncated, _budget) =
26451            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26452        assert!(!truncated);
26453        assert!(entries.iter().any(|entry| {
26454            entry.relative_path.as_utf8() == Some("src")
26455                && entry.kind == WorkspaceEntryKind::Directory
26456        }));
26457        assert!(entries.iter().any(|entry| {
26458            entry.relative_path.as_utf8() == Some("src/lib.rs")
26459                && entry.kind == WorkspaceEntryKind::File
26460        }));
26461        assert!(entries.iter().any(|entry| {
26462            entry.relative_path.as_utf8() == Some("target-notes")
26463                && entry.kind == WorkspaceEntryKind::Directory
26464        }));
26465        assert!(entries.iter().any(|entry| {
26466            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26467                && entry.kind == WorkspaceEntryKind::File
26468        }));
26469        assert!(entries.iter().all(|entry| {
26470            let path = entry.relative_path.as_utf8().unwrap();
26471            !path.starts_with(".git")
26472                && !(path == "target" || path.starts_with("target/"))
26473                && !(path == "target-a2" || path.starts_with("target-a2/"))
26474                && !path.starts_with("node_modules")
26475        }));
26476
26477        std::fs::remove_dir_all(&root).unwrap();
26478    }
26479
26480    #[test]
26481    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26482        let root = temporary_workspace_root("tree-entry-cap");
26483        std::fs::create_dir_all(&root).unwrap();
26484        for index in 0..20 {
26485            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26486        }
26487
26488        let (entries, truncated, budget) =
26489            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26490        assert!(truncated);
26491        assert!(budget.entry_cap_exceeded);
26492        assert!(!budget.time_budget_exceeded);
26493        assert!(entries.len() <= 5);
26494
26495        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26496            .expect("an entry-cap-truncated walk must report a truncation marker");
26497        assert!(inspection_truncation.walk_entry_cap_exceeded);
26498        assert!(!inspection_truncation.walk_time_budget_exceeded);
26499        assert!(!inspection_truncation.git_time_budget_exceeded);
26500        assert!(inspection_truncation.entries_visited >= 5);
26501
26502        std::fs::remove_dir_all(&root).unwrap();
26503    }
26504
26505    #[test]
26506    fn workspace_walk_within_budget_reports_no_truncation() {
26507        let root = temporary_workspace_root("tree-no-truncation");
26508        std::fs::create_dir_all(&root).unwrap();
26509        std::fs::write(root.join("only-file.txt"), 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        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26515
26516        std::fs::remove_dir_all(&root).unwrap();
26517    }
26518
26519    #[test]
26520    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26521        let root = temporary_workspace_root("tree-prefix-siblings");
26522        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26523        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26524        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26525        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26526
26527        let (entries, truncated, _budget) =
26528            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26529        assert!(!truncated);
26530        let paths = entries
26531            .iter()
26532            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26533            .collect::<Vec<_>>();
26534        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26535        assert_eq!(
26536            paths,
26537            vec![
26538                "gate4agent-c2".to_owned(),
26539                "gate4agent-c2-client".to_owned(),
26540                "gate4agent-c2-client/lib.rs".to_owned(),
26541                "gate4agent-c2/src".to_owned(),
26542                "gate4agent-c2/src/lib.rs".to_owned(),
26543            ],
26544        );
26545
26546        std::fs::remove_dir_all(&root).unwrap();
26547    }
26548
26549    #[test]
26550    fn git_parsers_cap_status_and_commit_results() {
26551        let mut status_output = Vec::new();
26552        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26553            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26554        }
26555        let mut snapshot = git_snapshot_for_parser();
26556        parse_git_status(&status_output, &mut snapshot);
26557        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26558        assert!(snapshot.truncated);
26559        assert_eq!(snapshot.status[0].index_status, " ");
26560        assert_eq!(snapshot.status[0].worktree_status, "M");
26561
26562        snapshot.truncated = false;
26563        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26564            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26565            .collect::<String>();
26566        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26567        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26568        assert!(snapshot.truncated);
26569    }
26570
26571    #[test]
26572    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26573        let mut snapshot = git_snapshot_for_parser();
26574        parse_git_status(
26575            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26576            &mut snapshot,
26577        );
26578
26579        assert!(!snapshot.truncated);
26580        assert_eq!(snapshot.status.len(), 3);
26581        assert_eq!(snapshot.status[0].index_status, "R");
26582        assert_eq!(snapshot.status[0].worktree_status, " ");
26583        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26584        assert_eq!(
26585            snapshot.status[0]
26586                .previous_path
26587                .as_ref()
26588                .and_then(RepositoryPath::as_utf8),
26589            Some("src/previous name.rs"),
26590        );
26591        assert_eq!(snapshot.status[1].index_status, " ");
26592        assert_eq!(snapshot.status[1].worktree_status, "C");
26593        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26594        assert_eq!(
26595            snapshot.status[1]
26596                .previous_path
26597                .as_ref()
26598                .and_then(RepositoryPath::as_utf8),
26599            Some("src/original.rs"),
26600        );
26601        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26602        assert!(snapshot.status[2].previous_path.is_none());
26603    }
26604
26605    #[test]
26606    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26607        let mut snapshot = git_snapshot_for_parser();
26608        parse_git_status(
26609            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26610            &mut snapshot,
26611        );
26612
26613        assert!(snapshot.truncated);
26614        assert_eq!(snapshot.status.len(), 1);
26615        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26616        assert!(snapshot.status[0].previous_path.is_none());
26617    }
26618
26619    #[tokio::test]
26620    async fn observer_can_browse_host_directories_without_controller() {
26621        let root = temporary_workspace_root("host-directory-observer");
26622        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26623        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26624        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26625        let workspace_id = WorkspaceId::new("primary").unwrap();
26626        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26627        let catalog = active_registry().unwrap();
26628        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26629        let shared = NodeShared::new(
26630            handle,
26631            "fixture-token".to_owned(),
26632            NodeId::new("node-1").unwrap(),
26633            vec![workspace],
26634            vec![agent("claude")],
26635        );
26636        let request = NodeRequest::BrowseHostDirectories {
26637            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26638            after: None,
26639        };
26640        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26641            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26642        }));
26643        assert!(!request_uses_unnegotiated_capability(
26644            &request,
26645            &baseline_capabilities().unwrap(),
26646        ));
26647        assert!(request_uses_unnegotiated_capability(&request, &[]));
26648
26649        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26650            .await
26651            .unwrap();
26652        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26653            panic!("host directory browse returned another response");
26654        };
26655        assert_eq!(
26656            listing
26657                .entries
26658                .iter()
26659                .map(|entry| entry.display_name.as_str())
26660                .collect::<Vec<_>>(),
26661            vec!["directory-a", "directory-b"],
26662        );
26663        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26664        assert!(!listing.incomplete);
26665        std::fs::remove_dir_all(root).unwrap();
26666    }
26667
26668    #[tokio::test]
26669    async fn observer_can_inspect_only_a_registered_workspace() {
26670        let root = temporary_workspace_root("observer");
26671        std::fs::create_dir_all(root.join("src")).unwrap();
26672        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26673        let workspace_id = WorkspaceId::new("primary").unwrap();
26674        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26675        let catalog = active_registry().unwrap();
26676        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26677        let shared = NodeShared::new(
26678            handle,
26679            "fixture-token".to_owned(),
26680            NodeId::new("node-1").unwrap(),
26681            vec![workspace],
26682            vec![agent("claude")],
26683        );
26684
26685        let response = process_request_inner(
26686            &shared,
26687            77,
26688            ClientRole::Observer,
26689            NodeRequest::InspectWorkspace {
26690                workspace_id: workspace_id.clone(),
26691            },
26692        )
26693        .await
26694        .unwrap();
26695        let NodeResponse::WorkspaceInspected { inspection } = response else {
26696            panic!("workspace inspection returned another response");
26697        };
26698        assert_eq!(inspection.workspace_id, workspace_id);
26699        assert!(inspection.entries.iter().any(|entry| {
26700            entry.relative_path.as_utf8() == Some("src/main.rs")
26701        }));
26702
26703        let unknown = process_request_inner(
26704            &shared,
26705            77,
26706            ClientRole::Observer,
26707            NodeRequest::InspectWorkspace {
26708                workspace_id: WorkspaceId::new("unknown").unwrap(),
26709            },
26710        )
26711        .await
26712        .unwrap_err();
26713        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26714        std::fs::remove_dir_all(&root).unwrap();
26715    }
26716
26717    #[test]
26718    fn context_pack_source_accepts_only_running_or_successful_exit() {
26719        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26720        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26721            exit_code: Some(0),
26722        }));
26723        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26724            exit_code: Some(1),
26725        }));
26726        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26727            exit_code: None,
26728        }));
26729        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26730            message: "provider failed".to_owned(),
26731        }));
26732        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26733        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26734        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26735    }
26736
26737    #[test]
26738    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26739        let git = git_snapshot_for_parser();
26740        let first = ContextPackRepositoryObservation {
26741            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26742            git: git.clone(),
26743            files: Vec::new(),
26744        };
26745        let second = ContextPackRepositoryObservation {
26746            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26747            git,
26748            files: Vec::new(),
26749        };
26750
26751        let error = stable_context_pack_repository(first, second).unwrap_err();
26752
26753        assert_eq!(
26754            error.code,
26755            NodeFailureCode::ContextPackMaterializationFailed,
26756        );
26757    }
26758
26759    #[test]
26760    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26761        let git = git_snapshot_for_parser();
26762        let first = ContextPackRepositoryObservation {
26763            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26764            git: git.clone(),
26765            files: vec![ContextPackRepositoryFileSource::utf8(
26766                "README.md",
26767                "first\n".to_owned(),
26768                6,
26769            )],
26770        };
26771        let second = ContextPackRepositoryObservation {
26772            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26773            git,
26774            files: vec![ContextPackRepositoryFileSource::utf8(
26775                "README.md",
26776                "other\n".to_owned(),
26777                6,
26778            )],
26779        };
26780
26781        let error = stable_context_pack_repository(first, second).unwrap_err();
26782
26783        assert_eq!(
26784            error.code,
26785            NodeFailureCode::ContextPackMaterializationFailed,
26786        );
26787    }
26788
26789    #[tokio::test]
26790    async fn native_session_catalog_zero_active_session_fixture() {
26791        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26792        let history_root = temporary_workspace_root("native-catalog-history");
26793        let outside_root = temporary_workspace_root("native-catalog-outside");
26794        std::fs::create_dir_all(&workspace_root).unwrap();
26795        std::fs::create_dir_all(&history_root).unwrap();
26796        std::fs::create_dir_all(&outside_root).unwrap();
26797        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26798            [
26799                serde_json::json!({
26800                    "type": "user",
26801                    "sessionId": session_id,
26802                    "cwd": cwd.to_str().unwrap(),
26803                    "message": { "content": "raw user text must not cross the catalog wire" }
26804                }),
26805                serde_json::json!({
26806                    "type": "assistant",
26807                    "sessionId": session_id,
26808                    "cwd": cwd.to_str().unwrap(),
26809                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26810                }),
26811                serde_json::json!({
26812                    "type": "ai-title",
26813                    "sessionId": session_id,
26814                    "aiTitle": title
26815                }),
26816            ]
26817            .into_iter()
26818            .map(|record| record.to_string())
26819            .collect::<Vec<_>>()
26820            .join("\n")
26821        };
26822        std::fs::write(
26823            history_root.join("stable-session.jsonl"),
26824            transcript("stable-session", &workspace_root, "Workspace review"),
26825        )
26826        .unwrap();
26827        std::fs::write(
26828            history_root.join("outside-session.jsonl"),
26829            transcript("outside-session", &outside_root, "Outside review"),
26830        )
26831        .unwrap();
26832        let history = NativeHistoryConfig::new(vec![
26833            NativeHistoryRoot::new(
26834                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26835                HistorySourceLayout::SingleNdjson,
26836                history_root.clone(),
26837            )
26838            .unwrap(),
26839        ])
26840        .unwrap();
26841        let catalog = active_registry().unwrap();
26842        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26843        assert!(handle.snapshot().sessions.is_empty());
26844        let workspace_id = WorkspaceId::new("primary").unwrap();
26845        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26846        let mut shared = NodeShared::new(
26847            handle,
26848            "fixture-token".to_owned(),
26849            NodeId::new("node-native-catalog").unwrap(),
26850            vec![workspace],
26851            vec![agent("claude")],
26852        );
26853        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26854            NativeSessionCatalogAuthority::new(history),
26855        )));
26856        let route = NativeSessionCatalogRoute::workspace(
26857            workspace_id.clone(),
26858            agent("claude"),
26859        );
26860        let request = NodeRequest::CatalogNativeSessions {
26861            route: route.clone(),
26862            limit: 2,
26863        };
26864        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26865            .await
26866            .unwrap_err();
26867        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26868        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26869            .await
26870            .unwrap();
26871        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26872            panic!("native catalog returned a different response");
26873        };
26874        let summary = summary.expect("node must emit native session catalog summary metadata");
26875        assert_eq!(entries.len(), 1);
26876        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26877        assert_eq!(entries[0].external_group, None);
26878        assert_eq!(entries[0].record_id, None);
26879        assert_eq!(entries[0].title, None);
26880        assert_eq!(entries[0].model, None);
26881        assert_eq!(entries[0].message_count, 0);
26882        let encoded_entries = serde_json::to_string(&entries).unwrap();
26883        assert!(!encoded_entries.contains("stable-session"));
26884        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26885        let page_request = NodeRequest::PageNativeSessions {
26886            route: route.clone(),
26887            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26888            catalog_revision: summary.catalog_revision,
26889            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26890            after_selection_id: None,
26891            limit: 2,
26892        };
26893        let observer = process_request_inner(
26894            &shared,
26895            7,
26896            ClientRole::Observer,
26897            page_request.clone(),
26898        )
26899        .await
26900        .unwrap_err();
26901        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26902        let page_response = process_request_inner(
26903            &shared,
26904            7,
26905            ClientRole::Operator,
26906            page_request.clone(),
26907        )
26908        .await
26909        .unwrap();
26910        assert!(matches!(
26911            page_response,
26912            NodeResponse::NativeSessionsPaged { page, .. }
26913                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26914        ));
26915        let stale = process_request_inner(
26916            &shared,
26917            7,
26918            ClientRole::Operator,
26919            NodeRequest::PageNativeSessions {
26920                route: route.clone(),
26921                window: crate::protocol::NativeSessionCatalogWindow::Recent,
26922                catalog_revision: summary.catalog_revision.wrapping_add(1),
26923                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26924                after_selection_id: None,
26925                limit: 2,
26926            },
26927        )
26928        .await
26929        .unwrap_err();
26930        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26931        let selection = NativeSessionSelection {
26932            route: route.clone(),
26933            catalog_revision: summary.catalog_revision,
26934            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26935            selection_id: entries[0].selection_id.clone(),
26936        };
26937        let preview_response = process_request_inner(
26938            &shared,
26939            7,
26940            ClientRole::Operator,
26941            NodeRequest::PreviewNativeSession {
26942                selection: selection.clone(),
26943                message_limit: 1,
26944            },
26945        )
26946        .await
26947        .unwrap();
26948        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26949            panic!("native preview returned a different response");
26950        };
26951        assert_eq!(preview.messages.len(), 1);
26952        assert!(preview.message_count_exact);
26953        assert_eq!(preview.messages[0].text, "raw answer");
26954        assert!(preview.truncated);
26955
26956        let index_request = NodeRequest::IndexNativeSession {
26957            selection: selection.clone(),
26958            display_name: "Saved Claude".to_owned(),
26959        };
26960        let observer = process_request_inner(
26961            &shared,
26962            7,
26963            ClientRole::Observer,
26964            index_request.clone(),
26965        )
26966        .await
26967        .unwrap_err();
26968        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26969        let without_controller = process_request_inner(
26970            &shared,
26971            7,
26972            ClientRole::Operator,
26973            index_request.clone(),
26974        )
26975        .await
26976        .unwrap_err();
26977        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26978        let acquired = process_request_inner(
26979            &shared,
26980            7,
26981            ClientRole::Operator,
26982            NodeRequest::AcquireController { lease_ms: 5_000 },
26983        )
26984        .await
26985        .unwrap();
26986        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26987        let indexed = process_request_inner(
26988            &shared,
26989            7,
26990            ClientRole::Operator,
26991            index_request,
26992        )
26993        .await
26994        .unwrap();
26995        assert!(indexed.native_session_index_contract_is_valid());
26996        let NodeResponse::NativeSessionIndexed {
26997            selection: echoed_selection,
26998            record,
26999        } = indexed else {
27000            panic!("native session index returned a different response");
27001        };
27002        assert_eq!(echoed_selection, selection);
27003        let recataloged = process_request_inner(
27004            &shared,
27005            7,
27006            ClientRole::Operator,
27007            NodeRequest::CatalogNativeSessions {
27008                route,
27009                limit: 2,
27010            },
27011        )
27012        .await
27013        .unwrap();
27014        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27015            panic!("native catalog returned a different response after indexing");
27016        };
27017        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27018
27019        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27020        let external_catalog = process_request_inner(
27021            &shared,
27022            7,
27023            ClientRole::Operator,
27024            NodeRequest::CatalogNativeSessions {
27025                route: unregistered_route.clone(),
27026                limit: 2,
27027            },
27028        )
27029        .await
27030        .unwrap();
27031        let external_json = serde_json::to_string(&external_catalog).unwrap();
27032        assert!(!external_json.contains("outside-session"));
27033        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27034        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27035        assert!(!external_json.contains("\"session_id\""));
27036        assert!(!external_json.contains("\"cwd\""));
27037        assert!(!external_json.contains("\"path\""));
27038        let NodeResponse::NativeSessionsCataloged {
27039            route: echoed_route,
27040            entries: external_entries,
27041            summary: Some(external_summary),
27042        } = external_catalog
27043        else {
27044            panic!("unregistered native catalog returned a different response");
27045        };
27046        assert_eq!(echoed_route, unregistered_route);
27047        assert_eq!(external_entries.len(), 1);
27048        assert_eq!(external_entries[0].record_id, None);
27049        let external_group = external_entries[0]
27050            .external_group
27051            .as_ref()
27052            .expect("unregistered session must have an external group");
27053        assert_eq!(
27054            external_group.kind,
27055            gate4agent_types::NativeSessionExternalGroupKind::Project,
27056        );
27057        assert!(external_group.group_id.starts_with("external-"));
27058        assert_eq!(
27059            external_group.display_name,
27060            outside_root.file_name().unwrap().to_string_lossy(),
27061        );
27062        let external_selection = NativeSessionSelection {
27063            route: unregistered_route,
27064            catalog_revision: external_summary.catalog_revision,
27065            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27066            selection_id: external_entries[0].selection_id.clone(),
27067        };
27068        let external_preview = process_request_inner(
27069            &shared,
27070            7,
27071            ClientRole::Operator,
27072            NodeRequest::PreviewNativeSession {
27073                selection: external_selection.clone(),
27074                message_limit: 1,
27075            },
27076        )
27077        .await
27078        .unwrap();
27079        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27080        assert!(!external_preview_json.contains("outside-session"));
27081        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27082        assert!(!external_preview_json.contains("\"session_id\""));
27083        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27084            panic!("unregistered native preview returned a different response");
27085        };
27086        assert_eq!(preview.messages.len(), 1);
27087        let external_index = process_request_inner(
27088            &shared,
27089            7,
27090            ClientRole::Operator,
27091            NodeRequest::IndexNativeSession {
27092                selection: external_selection,
27093                display_name: "External Claude".to_owned(),
27094            },
27095        )
27096        .await
27097        .unwrap_err();
27098        assert_eq!(
27099            external_index.code,
27100            NodeFailureCode::WorkspaceRegistrationRequired,
27101        );
27102        let record_response = process_request_inner(
27103            &shared,
27104            7,
27105            ClientRole::Operator,
27106            NodeRequest::PreviewSessionRecord {
27107                record_id: record.record_id.clone(),
27108                message_limit: 2,
27109            },
27110        )
27111        .await
27112        .unwrap();
27113        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27114            panic!("record preview returned a different response");
27115        };
27116        assert_eq!(record_id, record.record_id);
27117        assert_eq!(preview.messages.len(), 2);
27118        let encoded = serde_json::to_string(&preview).unwrap();
27119        assert!(!encoded.contains("stable-session"));
27120        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27121        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27122            panic!("record preview history summary returned a different response");
27123        };
27124        let summaries = events
27125            .iter()
27126            .filter_map(|envelope| match &envelope.event {
27127                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27128                    if record_id == &record.record_id =>
27129                {
27130                    Some(*summary)
27131                }
27132                _ => None,
27133            })
27134            .collect::<Vec<_>>();
27135        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27136        let summary_json = serde_json::to_string(&summaries).unwrap();
27137        for private in [
27138            "stable-session",
27139            "raw user text must not cross the catalog wire",
27140            "raw answer",
27141            "Workspace review",
27142            "claude-sonnet",
27143        ] {
27144            assert!(!summary_json.contains(private), "{private}");
27145        }
27146        let identity = record.provider_session.clone().unwrap();
27147        assert_eq!(
27148            shared.revalidate_session_record_preview(&record, &identity),
27149            Ok(())
27150        );
27151        shared.remove_record_memory(&record.record_id);
27152        assert_eq!(
27153            shared
27154                .revalidate_session_record_preview(&record, &identity)
27155                .unwrap_err()
27156                .code,
27157            NodeFailureCode::UnknownSessionRecord
27158        );
27159        let released = process_request_inner(
27160            &shared,
27161            7,
27162            ClientRole::Operator,
27163            NodeRequest::ReleaseController,
27164        )
27165        .await
27166        .unwrap();
27167        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27168        assert!(shared.controller_state().is_none());
27169        std::fs::remove_dir_all(workspace_root).unwrap();
27170        std::fs::remove_dir_all(history_root).unwrap();
27171        std::fs::remove_dir_all(outside_root).unwrap();
27172    }
27173
27174    #[tokio::test]
27175    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27176        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27177        std::fs::create_dir_all(&history_root).unwrap();
27178        let history = NativeHistoryConfig::new(vec![
27179            NativeHistoryRoot::new(
27180                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27181                HistorySourceLayout::SingleNdjson,
27182                history_root.clone(),
27183            )
27184            .unwrap(),
27185        ])
27186        .unwrap();
27187        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27188        let held = catalog.lock().unwrap();
27189        let result = timeout(
27190            Duration::from_secs(1),
27191            run_native_session_catalog_operation(
27192                Arc::clone(&catalog),
27193                "native session catalog",
27194                |_| Ok::<(), std::convert::Infallible>(()),
27195            ),
27196        )
27197        .await
27198        .expect("concurrent native catalog request must not wait for the operation deadline")
27199        .unwrap_err();
27200        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27201        drop(held);
27202        std::fs::remove_dir_all(history_root).unwrap();
27203    }
27204
27205    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27206        let unique = std::time::SystemTime::now()
27207            .duration_since(std::time::UNIX_EPOCH)
27208            .unwrap()
27209            .as_nanos();
27210        std::env::temp_dir().join(format!(
27211            "gate4agent-node-{label}-{}-{unique}",
27212            std::process::id(),
27213        ))
27214    }
27215
27216    fn git_snapshot_for_parser() -> GitSnapshot {
27217        GitSnapshot {
27218            is_repository: true,
27219            branch: Some("main".to_owned()),
27220            status: Vec::new(),
27221            recent_commits: Vec::new(),
27222            worktrees: Vec::new(),
27223            managed_worktree: None,
27224            truncated: false,
27225            diagnostic: None,
27226        }
27227    }
27228
27229    #[tokio::test]
27230    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27231        let root = temporary_workspace_root("managed-git-scope");
27232        std::fs::create_dir_all(&root).unwrap();
27233        run_test_git(&root, &["init"]);
27234        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27235        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27236        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27237        run_test_git(&root, &["add", "seed.txt"]);
27238        run_test_git(&root, &["commit", "-m", "seed"]);
27239        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27240
27241        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27242        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27243        let catalog = active_registry().unwrap();
27244        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27245        let shared = NodeShared::new(
27246            handle,
27247            "fixture-token".to_owned(),
27248            NodeId::new("node-1").unwrap(),
27249            vec![workspace],
27250            vec![agent("claude")],
27251        );
27252        let mut lease = ManagedWorktreeLeaseRecord {
27253            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27254            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27255            workspace_id: workspace_id.clone(),
27256            profile_id: WorktreeProfileId::new("default").unwrap(),
27257            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27258            target_root: root.to_string_lossy().into_owned(),
27259            branch: "gate4agent/a".to_owned(),
27260            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27261            expected_head: None,
27262            retention: ManagedWorktreeRetention::Retain,
27263            state: ManagedWorktreeLeaseState::InUse,
27264            session_holders: vec![ManagedWorktreeSessionHolder {
27265                incarnation_id: shared.incarnation_id,
27266                instance_id: AgentInstanceId(7),
27267                generation: SessionGeneration(1),
27268            }],
27269            record_holders: Vec::new(),
27270            cleanup_failure: None,
27271            created_at_unix_ms: 1,
27272            updated_at_unix_ms: 1,
27273        };
27274        *shared
27275            .managed_worktrees
27276            .lock()
27277            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27278            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27279
27280        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27281        let scope = inspection.git.managed_worktree.unwrap();
27282        assert_eq!(scope.lease_id, lease.lease_id);
27283        assert_eq!(scope.branch, "gate4agent/a");
27284        assert_eq!(scope.active_session_count, 1);
27285
27286        lease.branch = "gate4agent/b".to_owned();
27287        *shared
27288            .managed_worktrees
27289            .lock()
27290            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27291            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27292        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27293        assert!(mismatch.git.managed_worktree.is_none());
27294        std::fs::remove_dir_all(root).unwrap();
27295    }
27296
27297    #[tokio::test]
27298    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27299        let root = temporary_workspace_root("workspace-file-write-controller");
27300        std::fs::create_dir_all(root.join("src")).unwrap();
27301        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27302        let workspace_id = WorkspaceId::new("primary").unwrap();
27303        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27304        let catalog = active_registry().unwrap();
27305        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27306        let shared = NodeShared::new(
27307            handle,
27308            "fixture-token".to_owned(),
27309            NodeId::new("node-1").unwrap(),
27310            vec![workspace],
27311            vec![agent("claude")],
27312        );
27313        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27314        let expected_revision = workspace_file_revision(b"old\n");
27315        let request = NodeRequest::WriteWorkspaceFile {
27316            workspace_id: workspace_id.clone(),
27317            path: path.clone(),
27318            expected_revision: expected_revision.clone(),
27319            text: "new\n".to_owned(),
27320        };
27321        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27322            .await
27323            .unwrap_err();
27324        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27325        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27326            .await
27327            .unwrap_err();
27328        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27329        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27330        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27331        let NodeResponse::WorkspaceFileWritten { file } = response else {
27332            panic!("workspace file write returned another response");
27333        };
27334        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27335        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27336        let stale = process_request_inner(
27337            &shared,
27338            77,
27339            ClientRole::Operator,
27340            NodeRequest::WriteWorkspaceFile {
27341                workspace_id,
27342                path,
27343                expected_revision,
27344                text: "lost update\n".to_owned(),
27345            },
27346        )
27347        .await
27348        .unwrap_err();
27349        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27350        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27351        std::fs::remove_dir_all(root).unwrap();
27352    }
27353
27354    #[tokio::test]
27355    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27356        let root = temporary_workspace_root("workspace-entry-create-controller");
27357        std::fs::create_dir_all(root.join("src")).unwrap();
27358        let workspace_id = WorkspaceId::new("primary").unwrap();
27359        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27360        let catalog = active_registry().unwrap();
27361        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27362        let shared = NodeShared::new(
27363            handle,
27364            "fixture-token".to_owned(),
27365            NodeId::new("node-1").unwrap(),
27366            vec![workspace],
27367            vec![agent("claude")],
27368        );
27369        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27370        let request = NodeRequest::CreateWorkspaceFile {
27371            workspace_id: workspace_id.clone(),
27372            path: path.clone(),
27373        };
27374        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27375            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27376        }));
27377        assert!(!request_uses_unnegotiated_capability(
27378            &request,
27379            &baseline_capabilities().unwrap(),
27380        ));
27381        assert!(request_uses_unnegotiated_capability(&request, &[]));
27382        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27383            .await
27384            .unwrap_err();
27385        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27386        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27387            .await
27388            .unwrap_err();
27389        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27390        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27391
27392        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27393            .await
27394            .unwrap();
27395        let NodeResponse::WorkspaceFileCreated { file } = response else {
27396            panic!("workspace file create returned another response");
27397        };
27398        assert_eq!(file.workspace_id, workspace_id);
27399        assert_eq!(file.path, path);
27400        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27401        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27402
27403        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27404            .await
27405            .unwrap_err();
27406        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27407
27408        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27409        let response = process_request_inner(
27410            &shared,
27411            77,
27412            ClientRole::Operator,
27413            NodeRequest::CreateWorkspaceDirectory {
27414                workspace_id: workspace_id.clone(),
27415                path: directory_path.clone(),
27416            },
27417        )
27418        .await
27419        .unwrap();
27420        assert_eq!(
27421            response,
27422            NodeResponse::WorkspaceDirectoryCreated {
27423                workspace_id: workspace_id.clone(),
27424                entry: WorkspaceEntry {
27425                    relative_path: directory_path,
27426                    kind: WorkspaceEntryKind::Directory,
27427                },
27428            },
27429        );
27430        assert!(root.join("src/nested").is_dir());
27431
27432        let missing_parent = process_request_inner(
27433            &shared,
27434            77,
27435            ClientRole::Operator,
27436            NodeRequest::CreateWorkspaceFile {
27437                workspace_id: workspace_id.clone(),
27438                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27439            },
27440        )
27441        .await
27442        .unwrap_err();
27443        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27444        let non_directory_parent = process_request_inner(
27445            &shared,
27446            77,
27447            ClientRole::Operator,
27448            NodeRequest::CreateWorkspaceFile {
27449                workspace_id,
27450                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27451            },
27452        )
27453        .await
27454        .unwrap_err();
27455        assert_eq!(
27456            non_directory_parent.code,
27457            NodeFailureCode::RepositoryParentNotDirectory,
27458        );
27459        std::fs::remove_dir_all(root).unwrap();
27460    }
27461
27462    #[tokio::test]
27463    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27464        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27465        std::fs::create_dir_all(root.join("src")).unwrap();
27466        #[cfg(windows)]
27467        let root = std::fs::canonicalize(root).unwrap();
27468        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27469        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27470        let barrier = Arc::new(std::sync::Barrier::new(2));
27471        let done = Arc::new(AtomicBool::new(false));
27472        let task_commit_state = Arc::clone(&commit_state);
27473        let task_barrier = Arc::clone(&barrier);
27474        let task_done = Arc::clone(&done);
27475        let task_root = root.clone();
27476        let task = tokio::task::spawn_blocking(move || {
27477            task_barrier.wait();
27478            let result = create_workspace_file_on_disk(
27479                &task_root,
27480                &path,
27481                &task_commit_state,
27482            );
27483            task_done.store(true, Ordering::Release);
27484            result
27485        });
27486
27487        let error = settle_workspace_entry_create(
27488            task,
27489            Arc::clone(&commit_state),
27490            Duration::from_millis(10),
27491            "fixture deadline",
27492            "fixture task failed",
27493        )
27494        .await
27495        .unwrap_err();
27496        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27497        assert_eq!(
27498            commit_state.load(Ordering::Acquire),
27499            WORKSPACE_ENTRY_CREATE_CANCELED,
27500        );
27501
27502        barrier.wait();
27503        timeout(Duration::from_secs(1), async {
27504            while !done.load(Ordering::Acquire) {
27505                tokio::task::yield_now().await;
27506            }
27507        })
27508        .await
27509        .expect("canceled blocking create did not settle");
27510        assert!(!root.join("src/late.rs").exists());
27511        std::fs::remove_dir_all(root).unwrap();
27512    }
27513
27514}