Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod node_wake;
213
214mod browser_bridge;
215
216use gate4agent_node_core::drive_loop_profile;
217
218#[path = "bundle_delivery.rs"]
219mod bundle_delivery;
220
221#[path = "context_pack_store.rs"]
222mod context_pack_store;
223
224#[cfg(windows)]
225pub use crate::platform::DEFAULT_NODE_ENDPOINT;
226
227const NODE_EVENT_HISTORY_MAX: usize = 4_096;
228const NODE_BROADCAST_CAPACITY: usize = 1_024;
229const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
230/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
231/// terminal frame is a full-screen snapshot; only the latest ever matters),
232/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
233/// two pending sends into one would silently drop content a subscriber can
234/// never recover. This needs headroom for a burst instead.
235const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
236/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
237/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
238/// admission check), and `publish_transient` sends each exactly once as a
239/// single-envelope batch -- so this many slots covers the entire pending
240/// set without ever lagging the one subscriber that matters, the harness's
241/// own connection, the way sharing the durable `event_tx`'s bus could.
242const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
243const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
244const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
245const MAX_PREAUTH_CONNECTIONS: usize = 32;
246const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
247const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
248const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
249const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
250const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
251/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
252/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
253/// not the disconnect reconnect grace (HQ owns that, starting only on a real
254/// client WS close). A provider conversation id is a session; a PTY with no
255/// attached HQ view is still a draft and is reaped here if attach never lands.
256const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
257/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
258/// record retention policy (`NodeServerConfig::session_record_retention`).
259/// A sweep cadence, not a policy value -- the policy itself lives entirely
260/// in the operator-configured age/keep-N flags; this just bounds how stale
261/// the in-memory view of dead `Unavailable` records can get between sweeps
262/// on an otherwise-idle node, mirroring the once-per-startup sweep that
263/// also runs alongside `reconcile_managed_worktrees`.
264const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
265/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
266/// field's own doc for why a spawn waiter needs this at all. Must outlive
267/// the longest a waiter can still be polling for this exact command's
268/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
269/// largest `deadline_ms` any caller may request, and the margin on top
270/// absorbs the waiter's own poll cadence plus clock skew between the drive
271/// loop (writer) and the waiter (reader).
272const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
273/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
274/// backlog of asynchronously-rejected commands nobody ever collects (a
275/// waiter that stops polling, or a burst of always-rejected commands from a
276/// misbehaving caller): once at capacity, the oldest entry is evicted to
277/// make room for the newest, mirroring the eviction rule already used by
278/// `spawn_idempotency`.
279const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
280/// How long `wait_until_removed` waits between two `Remove` re-dispatches
281/// for the same session. The presence poll runs far faster (2ms); this
282/// only paces the sending, so an accepted `Remove` still in flight is not
283/// piled on by a dozen more that land on an already-dropped instance.
284const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
285const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
286const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
287const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
288const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
289const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
290const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
291const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
292const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
293const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
294const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
295const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
296const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
297/// Default total wall-clock budget for `inspect_workspace`'s walk+git
298/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
299const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
300/// The harness relay enforces its own flat response deadline
301/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
302/// 12s) above this node-side budget. The two constants live in different
303/// crates with no shared definition, so clamp any override here comfortably
304/// below that external ceiling — a misconfigured value above it would
305/// reopen the exact deadline race this budget exists to close.
306const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
307/// Default cap on directory entries visited (not just returned) by one
308/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
309const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
310const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
311const GIT_STATUS_MAX_ENTRIES: usize = 128;
312const GIT_COMMIT_MAX_ENTRIES: usize = 12;
313const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
314const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
315const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
316const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
317const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
318const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
319const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
320const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
321const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
322const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
323    "provider-session-identity-allocation-failed";
324const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
325const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
326const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
327const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
328const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
329const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
330const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
331    "durable-state-path-semantics-unsupported";
332
333fn opaque_windows_path(value: String) -> OpaqueHostPath {
334    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
335}
336
337fn agent_progress_from_provider_snapshot(
338    address: SessionAddress,
339    provider: &ProviderSnapshot,
340) -> Option<SessionAgentProgress> {
341    let mut truncated = false;
342    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
343    let mut active_tool_labels = Vec::with_capacity(
344        provider
345            .active_tools
346            .len()
347            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
348    );
349    for tool in &provider.active_tools {
350        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
351            truncated = true;
352            break;
353        }
354        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
355            active_tool_labels.push(label);
356        }
357    }
358    let attention = provider
359        .interactions
360        .iter()
361        .find(|interaction| {
362            matches!(
363                interaction.status,
364                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
365            )
366        })
367        .map(|interaction| AgentProgressAttentionV1 {
368            kind: match interaction.interaction_kind {
369                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
370                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
371            },
372            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
373        });
374    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
375    let usage = (provider.completed_turns > 0
376        || provider.usage.input_tokens > 0
377        || provider.usage.output_tokens > 0
378        || provider.usage.cache_read_tokens > 0
379        || provider.usage.cache_write_tokens > 0
380        || provider.usage.reasoning_tokens > 0)
381        .then_some(AgentProgressUsageV1 {
382            input_tokens: provider.usage.input_tokens,
383            output_tokens: provider.usage.output_tokens,
384            cache_read_tokens: provider.usage.cache_read_tokens,
385            cache_write_tokens: provider.usage.cache_write_tokens,
386            reasoning_tokens: provider.usage.reasoning_tokens,
387        });
388    let progress = AgentProgressV1 {
389        provider_sequence: provider.sequence,
390        activity: provider.activity,
391        completed_turns: provider.completed_turns,
392        usage,
393        current: AgentProgressCurrentV1::from(provider.activity),
394        active_tool_labels,
395        active_tool_count,
396        attention,
397        subagent_count,
398        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
399        gap_count: provider.gap_count,
400        stale: provider.stale,
401        truncated,
402    };
403    let entry = SessionAgentProgress { address, progress };
404    serde_json::to_vec(&entry)
405        .ok()
406        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
407        .map(|_| entry)
408}
409
410fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
411    match u32::try_from(count) {
412        Ok(count) => count,
413        Err(_) => {
414            *truncated = true;
415            u32::MAX
416        }
417    }
418}
419
420fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
421    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
422        return None;
423    }
424    Some(match event {
425        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
426        ProviderEvent::SessionIdentityObserved { .. } => {
427            AgentProgressEventKindV1::SessionIdentityObserved
428        }
429        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
430        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
431        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
432        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
433        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
434        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
435        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
436        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
437        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
438        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
439        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
440        ProviderEvent::InteractionRequested { .. } => {
441            AgentProgressEventKindV1::InteractionRequested
442        }
443        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
444        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
445        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
446        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
447        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
448        ProviderEvent::UnrecognizedNotification { .. } => {
449            AgentProgressEventKindV1::UnrecognizedNotification
450        }
451        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
452        // ACP session/update coverage beyond text/tool/turn streaming
453        // (`plan`, `available_commands_update`, `current_mode_update`,
454        // `session_info_update`, `usage_update`, `config_option_update`,
455        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
456        // wire enum without a `#[serde(other)]` fallback; minting new
457        // variants for it is a deliberate wire-contract decision for
458        // whoever owns this protocol, not a side effect of parsing more of
459        // ACP's own wire -- until that decision is made, these fold into
460        // the same "we saw something, no dedicated kind for it yet" bucket
461        // as `UnrecognizedNotification`.
462        ProviderEvent::UserMessage { .. }
463        | ProviderEvent::Plan { .. }
464        | ProviderEvent::AvailableCommandsUpdated { .. }
465        | ProviderEvent::ModeChanged { .. }
466        | ProviderEvent::SessionInfoUpdated { .. }
467        | ProviderEvent::UsageUpdated { .. }
468        | ProviderEvent::ConfigOptionsUpdated { .. } => {
469            AgentProgressEventKindV1::UnrecognizedNotification
470        }
471    })
472}
473
474/// Maps this harness's own `HostDecisionAuthority` onto the wider
475/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
476/// chunk carries -- the four authorities a `Denied` decision is ever made
477/// under, one-for-one. The remaining `BlockAuthorityV1` variants
478/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
479/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
480/// block the PROVIDER side of the wire reported -- this function never
481/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
482/// them; the provider-side blocks below mint those directly from typed fields.
483/// Whether a provider source is a live session transport -- the only kind
484/// whose events reach the agent stream. History, resume, session-option and
485/// capability-probe sources are not sessions and stream nothing.
486fn provider_family_is_live(family: AdapterFamily) -> bool {
487    match family {
488        AdapterFamily::PtySemantic
489        | AdapterFamily::Hook
490        | AdapterFamily::ManagedHook
491        | AdapterFamily::Pipe
492        | AdapterFamily::OneShot
493        | AdapterFamily::Acp => true,
494        AdapterFamily::History
495        | AdapterFamily::Resume
496        | AdapterFamily::SessionOptions
497        | AdapterFamily::CapabilityProbe => false,
498    }
499}
500
501fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
502    match by {
503        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
504        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
505        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
506        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
507    }
508}
509
510/// The `Blocked::reason` for a `Denied` decision this build's own
511/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
512/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
513/// request_permission` request resolved by `AcpSession::
514/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
515/// neither of which computes free text (an operator answers approve/reject,
516/// never a comment; an unattended deadline asks nobody anything). `reason`
517/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
518/// -- a categorical fallback naming the authority is the
519/// honest minimum, never a placeholder standing in for detail nobody
520/// reported.
521fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
522    match authority {
523        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
524        BlockAuthorityV1::HostPolicy => "denied by host policy",
525        BlockAuthorityV1::HostDeadline => {
526            "denied by host policy after the operator deadline expired"
527        }
528        BlockAuthorityV1::Operator => "declined by the operator",
529        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
530        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
531        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
532        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
533        BlockAuthorityV1::UserRejected => "rejected by the user",
534        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
535        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
536    }
537}
538
539/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
540/// for a `HostRequestObserved` `Denied` decision.
541fn host_request_denied_block(
542    method: &str,
543    by: ProviderHostDecisionAuthority,
544    reason: Option<&str>,
545) -> (String, BlockAuthorityV1, String) {
546    let authority = block_authority(by);
547    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
548    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
549    // rather than truncating -- this cut happens here, at the producer, before
550    // the validator ever sees the text.
551    let (reason_text, _) =
552        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
553    (tool_class_label(method), authority, reason_text)
554}
555
556// ---------------------------------------------------------------------------
557// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
558// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
559// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
560// prompt` RPC error whose vendor code names a quota/limit/rate
561// exhaustion). See
562// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
563// §3 slice K2.
564// ---------------------------------------------------------------------------
565
566/// Classifies ACP's `nonExecutionKind` into the typed authority a block
567/// this build mints under, or `None` when the tool truly did not run for a
568/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
569/// TURN stopped (session closed, the operator cancelled), not that a
570/// policy/classifier/rule refused the call, so those (and any unrecognized
571/// future value) stay a plain `ToolCompleted { success: false }` only, per
572/// this plan's own rule that a tool failure is never inflated into a block
573/// this build cannot actually attribute.
574fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
575    match kind? {
576        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
577        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
578        _ => None,
579    }
580}
581
582/// Splits a provider's own denial sentence into `reason`/`help`, when it
583/// matches the fixed shape Claude Code's classifier/permission-rule denial
584/// uses -- verified live, the owner's sample fixture
585/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
586/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
587/// `authority` is NEVER derived from this text (see this module's own rule
588/// that authority comes only from a typed field) -- only `reason`/`help`
589/// are. Falls back to the full text as `reason` with no `help` when the
590/// shape does not match (a provider whose text does not carry this exact
591/// wording) -- the honest minimum: the tool truly did not run, so SOME
592/// reason is always reported, never fabricated detail beyond what arrived.
593fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
594    let trimmed = text.trim();
595    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
596        return (trimmed.to_owned(), None);
597    };
598    let tail = after_reason.trim_start();
599    let Some((reason, help)) = tail.split_once(". ") else {
600        // "Reason:" present but no ". " to split help off -- the whole
601        // tail is the reason, e.g. "Reason: Blocked by classifier." with
602        // no help text following on some providers/versions.
603        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
604        return (reason.to_owned(), None);
605    };
606    let help = help.trim();
607    if help.is_empty() {
608        (reason.trim().to_owned(), None)
609    } else {
610        (reason.trim().to_owned(), Some(help.to_owned()))
611    }
612}
613
614/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
615/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
616/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
617/// name is never threaded through to its completion). Reads the class off
618/// the guidance tail's own "add a <Class> permission rule" sentence when
619/// present (the shape the owner's sample fixture uses) -- text matching
620/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
621/// this module's own rule). Falls back to the same generic `"Tool"` class
622/// `ToolCompleted`'s own observation already uses when nothing names one.
623fn tool_class_from_denial_help(help: Option<&str>) -> String {
624    help.and_then(|text| text.split("add a ").nth(1))
625        .and_then(|tail| tail.split(' ').next())
626        .filter(|word| !word.is_empty())
627        .map(str::to_owned)
628        .unwrap_or_else(|| "Tool".to_owned())
629}
630
631/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
632/// message) names a quota/rate-limit exhaustion -- Codex's
633/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
634/// contains "limit", the word this build actually keys on; "quota"/"rate"
635/// are kept for whichever OTHER vendor's own code names one of those
636/// instead, never confirmed live, but the honest generalization of the
637/// one confirmed sample.
638fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
639    fn names_quota(s: &str) -> bool {
640        let lower = s.to_ascii_lowercase();
641        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
642    }
643    vendor_code.is_some_and(names_quota) || names_quota(message)
644}
645
646/// The `Blocked` chunk this build mints when a turn stopped via a
647/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
648/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
649/// vendor code (or message) names a quota/limit/rate exhaustion
650/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
651/// `reason_kind` = the vendor code). `None` for every other stop --
652/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
653/// `Other`, or an RPC error whose vendor code/message names none of the
654/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
655/// those, never inflated into a block this build cannot actually
656/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
657/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
658/// honest minimum names the fact that happened, never invented detail; a
659/// later slice that threads the turn's last agent text chunk through can
660/// replace it without changing this function's contract.
661fn provider_session_end_block(
662    stop_reason: &ProviderStopReason,
663) -> Option<(BlockAuthorityV1, String, String)> {
664    match stop_reason {
665        ProviderStopReason::Refusal => Some((
666            BlockAuthorityV1::ProviderRefusal,
667            "refusal".to_owned(),
668            "the provider ended the turn with stopReason: refusal".to_owned(),
669        )),
670        ProviderStopReason::ProviderError { message, vendor_code, .. }
671            if provider_error_is_quota(vendor_code.as_deref(), message) =>
672        {
673            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
674            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
675        }
676        _ => None,
677    }
678}
679
680/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
681/// `agent-stream-events-v1` channel -- see
682/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
683/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
684/// event it derives from, so a client joining the two sees one id and one
685/// ordering:
686///
687/// - `source_sequence` on the chunk is the `source_sequence` the provider
688///   event itself carries.
689/// - `InteractionPrompt::correlation_id` is minted by
690///   `correlation::interaction_correlation` with the
691///   `(instance_id, generation, provider_sequence)` triple -- never the
692///   provider's own `request_id`, whose opacity on the wire is pinned by a
693///   dedicated test.
694///
695/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
696/// (nothing else carries stream content), for a source that is not a live
697/// session transport, and for the `ProviderEvent` variants below with no
698/// declared chunk kind to carry them, or whose declared chunk kind this build
699/// has no source for yet:
700///
701/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
702///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
703///   yet, the same gap the plan names explicitly.
704/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
705///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
706///   as "what the agent is saying", so mapping the operator's words onto
707///   `Text` would misrepresent whose content it is. No dedicated
708///   user-message kind is declared, so this stays unproduced rather than
709///   mislabeled.
710///
711/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
712/// decision returns `None` (nothing for the operator to react to), a `Denied`
713/// decision returns `Some(Blocked { .. })`.
714///
715/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
716/// below (K2): each returns `None` unless
717/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
718/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
719/// RPC error naming a quota).
720fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
721    let ControlEventKind::ProviderEvent {
722        sequence: provider_sequence,
723        source,
724        source_sequence,
725        event: provider_event,
726    } = &event.event
727    else {
728        return None;
729    };
730    if !provider_family_is_live(source.family) {
731        return None;
732    }
733    let kind = match provider_event {
734        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
735            text: text.clone(),
736            is_delta: *is_delta,
737        },
738        ProviderEvent::Thinking { text } => {
739            AgentStreamChunkKindV1::Thinking { text: text.clone() }
740        }
741        ProviderEvent::InteractionRequested {
742            interaction_kind,
743            tool_name,
744            title,
745            prompt,
746            options,
747            ..
748        } => AgentStreamChunkKindV1::InteractionPrompt {
749            correlation_id: interaction_correlation(
750                event.instance_id,
751                event.generation,
752                *provider_sequence,
753            ),
754            interaction_kind: *interaction_kind,
755            tool_name: tool_name.clone(),
756            // `title`/`options` are carried straight through from
757            // `ProviderEvent::InteractionRequested`, as the agent itself
758            // gave them on `session/request_permission`'s `PermissionTool
759            // Call.title`/`options` (see `gate4agent-shell-native`'s
760            // `provider_event`, the only ACP-transport producer). A PTY- or
761            // hook-sourced interaction carries `title: None` and
762            // `options: []` from its own producer because that source has
763            // none to offer, never because this mapping dropped one.
764            title: title.clone(),
765            prompt: prompt.clone(),
766            options: options
767                .iter()
768                .map(|option| AgentStreamInteractionOptionV1 {
769                    option_id: option.option_id.clone(),
770                    name: option.name.clone(),
771                    kind: option.kind.clone(),
772                })
773                .collect(),
774        },
775        ProviderEvent::ConfigOptionsUpdated { options } => {
776            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
777        }
778        // `available` is the mode catalogue `AcpSession::available_modes()`
779        // read back at `session/new` and threaded through by
780        // `gate4agent-shell-native`'s `provider_event` (see that crate's
781        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
782        // agent's own honest "no modes announced", never "not read": ACP
783        // orders `session/new` before any `session/update`, and the
784        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
785        // exist before the same session object's catalogue is already
786        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
787        // own doc comment for the ordering argument in full).
788        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
789            current: Some(mode_id.clone()),
790            available: available
791                .iter()
792                .map(|mode| AgentStreamNamedIdV1 {
793                    id: mode.id.clone(),
794                    name: mode.name.clone(),
795                    description: mode.description.clone(),
796                })
797                .collect(),
798        },
799        // A denied host request is a block -- see this function's own doc
800        // comment for why `HostRequestObserved` is not in the exclusion list
801        // below.
802        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
803            let ProviderHostRequestDecision::Denied { by } = decision else {
804                return None;
805            };
806            let (tool_class, authority, reason_text) =
807                host_request_denied_block(method, *by, reason.as_deref());
808            AgentStreamChunkKindV1::Blocked {
809                correlation_id: None,
810                tool_class,
811                authority,
812                reason_kind: None,
813                reason: reason_text,
814                help: None,
815            }
816        }
817        // Same relationship as `HostRequestObserved` above, driven by ACP's
818        // own `_meta.nonExecutionKind` instead of the host's
819        // `HostRequestDecision`.
820        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
821            let (authority, reason_kind) =
822                provider_tool_block_authority(non_execution_kind.as_deref())?;
823            let (reason, help) = split_provider_denial_text(output);
824            let (reason, _) =
825                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
826            let help = help.map(|help| {
827                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
828            });
829            AgentStreamChunkKindV1::Blocked {
830                correlation_id: Some(tool_correlation(event, source, id)),
831                tool_class: tool_class_from_denial_help(help.as_deref()),
832                authority,
833                reason_kind: Some(reason_kind.to_owned()),
834                reason,
835                help,
836            }
837        }
838        // Same relationship, driven by `stopReason: "refusal"` or a
839        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
840        // instead of a tool-level fact.
841        ProviderEvent::SessionEnded { stop_reason, .. } => {
842            let (authority, reason_kind, reason) =
843                stop_reason.as_ref().and_then(provider_session_end_block)?;
844            let (reason, _) =
845                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
846            AgentStreamChunkKindV1::Blocked {
847                correlation_id: None,
848                tool_class: "Session".to_owned(),
849                authority,
850                reason_kind: Some(reason_kind),
851                reason,
852                help: None,
853            }
854        }
855        ProviderEvent::SessionStarted { .. }
856        | ProviderEvent::SessionIdentityObserved { .. }
857        | ProviderEvent::TurnStarted { .. }
858        | ProviderEvent::WorkingObserved
859        | ProviderEvent::ToolStarted { .. }
860        | ProviderEvent::TurnCompleted { .. }
861        | ProviderEvent::ContextWindowUsage { .. }
862        | ProviderEvent::TurnInterrupted
863        | ProviderEvent::Error { .. }
864        | ProviderEvent::Ready
865        | ProviderEvent::InteractionResolved { .. }
866        | ProviderEvent::SubagentStarted { .. }
867        | ProviderEvent::SubagentStopped { .. }
868        | ProviderEvent::RateLimited { .. }
869        | ProviderEvent::UnrecognizedNotification { .. }
870        | ProviderEvent::UserMessage { .. }
871        | ProviderEvent::Plan { .. }
872        | ProviderEvent::AvailableCommandsUpdated { .. }
873        | ProviderEvent::SessionInfoUpdated { .. }
874        | ProviderEvent::UsageUpdated { .. } => return None,
875    };
876    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
877}
878
879async fn run_native_session_catalog_operation<T, E, F>(
880    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
881    operation: &'static str,
882    execute: F,
883) -> Result<T, NodeFailure>
884where
885    T: Send + 'static,
886    E: std::fmt::Display + Send + 'static,
887    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
888{
889    let task = tokio::task::spawn_blocking(move || {
890        let mut catalog = match catalog.try_lock() {
891            Ok(catalog) => catalog,
892            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
893            Err(std::sync::TryLockError::WouldBlock) => {
894                return Err(failure(
895                    NodeFailureCode::BackendBusy,
896                    "native session catalog operation is already running",
897                ));
898            }
899        };
900        execute(&mut catalog).map_err(|error| {
901            failure(
902                NodeFailureCode::BackendOperationFailed,
903                &format!("{operation} failed: {error}"),
904            )
905        })
906    });
907    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
908        .await
909        .map_err(|_| {
910            failure(
911                NodeFailureCode::BackendBusy,
912                &format!("{operation} exceeded its bounded deadline"),
913            )
914        })?
915        .map_err(|_| {
916            failure(
917                NodeFailureCode::BackendOperationFailed,
918                &format!("{operation} task failed"),
919            )
920        })?
921}
922
923fn windows_path_text(path: &OpaqueHostPath) -> &str {
924    path.as_utf8()
925        .expect("Windows node only creates UTF-8 opaque host paths")
926}
927
928fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
929    path.as_utf8()
930        .map(str::to_owned)
931        .ok_or_else(|| failure(
932            NodeFailureCode::InvalidRequest,
933            "this node requires UTF-8 host paths",
934        ))
935}
936
937fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
938    GitWorktreeSnapshot {
939        path: opaque_windows_path(worktree.path),
940        head: worktree.head,
941        branch: worktree.branch,
942        is_bare: worktree.is_bare,
943        is_main: worktree.is_main,
944        locked: worktree.locked,
945        lock_reason: worktree.lock_reason,
946        prunable: worktree.prunable,
947        prunable_reason: worktree.prunable_reason,
948        workspace_id: worktree.workspace_id.take(),
949    }
950}
951
952#[derive(Clone, Debug, Eq, PartialEq)]
953pub struct WorkspaceConfig {
954    workspace_id: WorkspaceId,
955    canonical_root: String,
956    worktree_service_mode: WorktreeServiceMode,
957    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
958}
959
960impl WorkspaceConfig {
961    pub fn new(
962        workspace_id: WorkspaceId,
963        root: impl AsRef<Path>,
964    ) -> Result<Self, NodeServerError> {
965        let root = root.as_ref();
966        if !root.is_absolute() {
967            return Err(NodeServerError::InvalidWorkspaceRoot {
968                workspace_id,
969                path: root.to_string_lossy().into_owned(),
970                message: "path is not absolute".to_owned(),
971            });
972        }
973        let canonical = std::fs::canonicalize(root).map_err(|error| {
974            NodeServerError::InvalidWorkspaceRoot {
975                workspace_id: workspace_id.clone(),
976                path: root.to_string_lossy().into_owned(),
977                message: error.to_string(),
978            }
979        })?;
980        if !canonical.is_dir() {
981            return Err(NodeServerError::InvalidWorkspaceRoot {
982                workspace_id,
983                path: root.to_string_lossy().into_owned(),
984                message: "path is not a directory".to_owned(),
985            });
986        }
987        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
988            NodeServerError::InvalidWorkspaceRoot {
989                workspace_id: workspace_id.clone(),
990                path: path.to_string_lossy().into_owned(),
991                message: "canonical path is not valid Unicode".to_owned(),
992            }
993        })?;
994        let canonical_root = platform::normalize_canonical_root(canonical_root);
995        validate_workspace_root(&workspace_id, &canonical_root)?;
996        Ok(Self {
997            workspace_id,
998            canonical_root,
999            worktree_service_mode: WorktreeServiceMode::Manual,
1000            managed_worktree_profiles: BTreeMap::new(),
1001        })
1002    }
1003
1004    pub fn workspace_id(&self) -> &WorkspaceId {
1005        &self.workspace_id
1006    }
1007
1008    pub fn canonical_root(&self) -> &str {
1009        &self.canonical_root
1010    }
1011
1012    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1013        self.worktree_service_mode = mode;
1014        self
1015    }
1016
1017    pub fn with_managed_worktree_profile(
1018        mut self,
1019        profile: ManagedWorktreeProfile,
1020    ) -> Result<Self, NodeServerError> {
1021        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1022            NodeServerError::InvalidManagedWorktreeProfile {
1023                workspace_id: self.workspace_id.clone(),
1024                profile_id: profile.profile_id().clone(),
1025                message,
1026            }
1027        })?;
1028        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1029            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1030                workspace_id: self.workspace_id,
1031                profile_id: profile.profile_id().clone(),
1032            });
1033        }
1034        if self.managed_worktree_profiles.len()
1035            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1036        {
1037            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1038                workspace_id: self.workspace_id,
1039                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1040            });
1041        }
1042        self.managed_worktree_profiles
1043            .insert(profile.profile_id().clone(), profile);
1044        Ok(self)
1045    }
1046}
1047
1048#[derive(Clone)]
1049pub struct NodeServerConfig {
1050    pub endpoint: String,
1051    api_listen: Option<std::net::SocketAddr>,
1052    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1053    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1054    bridge_listen: Option<std::net::SocketAddr>,
1055    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1056    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1057    bridge_token: Option<String>,
1058    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1059    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1060    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1061    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1062    /// node purely a listener, which is what it has always been.
1063    call_home: Option<std::net::SocketAddr>,
1064    /// When set, call-home authenticates by signing the relay's challenge with this
1065    /// identity (enrolled node) and falls back to the token handshake against a relay
1066    /// that sends no challenge.
1067    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1068    pub node_id: NodeId,
1069    pub workspaces: Vec<WorkspaceConfig>,
1070    access_token: String,
1071    pub runtime: NativeRuntimeConfig,
1072    session_record_retention: SessionRecordRetentionConfig,
1073    state_path: Option<PathBuf>,
1074    spawn_profiles: SpawnProfileRegistry,
1075    session_environment: Option<NodeSessionEnvironmentConfig>,
1076    history: Option<NativeHistoryConfig>,
1077    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1078    /// Station network allowlist catalog (opaque ids). Empty default.
1079    /// Loaded at startup from `--network-allowlist-catalog` / env
1080    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1081    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1082    #[cfg(feature = "fixture")]
1083    fixture_raw_pty_runtime: bool,
1084}
1085
1086#[derive(Clone)]
1087struct NodeSessionEnvironmentConfig {
1088    root: PathBuf,
1089    resolver: Arc<dyn NodeSecretResolver>,
1090}
1091
1092impl NodeServerConfig {
1093    pub fn new(
1094        endpoint: impl Into<String>,
1095        access_token: impl Into<String>,
1096        node_id: NodeId,
1097        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1098    ) -> Result<Self, NodeServerError> {
1099        let endpoint = endpoint.into();
1100        let access_token = access_token.into();
1101        if !platform::validate_endpoint(&endpoint) {
1102            return Err(NodeServerError::InvalidEndpoint);
1103        }
1104        if access_token.is_empty() || access_token.len() > 4_096 {
1105            return Err(NodeServerError::InvalidAccessToken);
1106        }
1107        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1108        if workspaces.is_empty() {
1109            return Err(NodeServerError::NoWorkspaces);
1110        }
1111        let mut workspace_ids = BTreeMap::new();
1112        let mut workspace_roots = BTreeMap::new();
1113        for workspace in &workspaces {
1114            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1115                && workspace.managed_worktree_profiles.is_empty()
1116            {
1117                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1118                    workspace.workspace_id.clone(),
1119                ));
1120            }
1121            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1122                && !workspace.managed_worktree_profiles.is_empty()
1123            {
1124                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1125                    workspace.workspace_id.clone(),
1126                ));
1127            }
1128            if workspace_ids
1129                .insert(workspace.workspace_id.clone(), ())
1130                .is_some()
1131            {
1132                return Err(NodeServerError::DuplicateWorkspaceId(
1133                    workspace.workspace_id.clone(),
1134                ));
1135            }
1136            let root_key = platform::root_identity(&workspace.canonical_root);
1137            if let Some(existing) = workspace_roots.insert(
1138                root_key,
1139                workspace.workspace_id.clone(),
1140            ) {
1141                return Err(NodeServerError::DuplicateWorkspaceRoot {
1142                    first: existing,
1143                    second: workspace.workspace_id.clone(),
1144                    root: workspace.canonical_root.clone(),
1145                });
1146            }
1147        }
1148        Ok(Self {
1149            endpoint,
1150            api_listen: None,
1151            bridge_listen: None,
1152            bridge_token: None,
1153            bridge_underlay: None,
1154            call_home: None,
1155            call_home_identity: None,
1156            node_id,
1157            workspaces,
1158            access_token,
1159            // PTY output is polled by the runtime's effect worker; the upstream
1160            // default (20ms) added ~10-20ms to every keystroke echo.
1161            runtime: NativeRuntimeConfig {
1162                worker_poll_interval_ms: 4,
1163                ..NativeRuntimeConfig::default()
1164            },
1165            session_record_retention: SessionRecordRetentionConfig::default(),
1166            state_path: None,
1167            spawn_profiles: SpawnProfileRegistry::default(),
1168            session_environment: None,
1169            history: None,
1170            harness_mcp_helper: None,
1171            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1172            #[cfg(feature = "fixture")]
1173            fixture_raw_pty_runtime: false,
1174        })
1175    }
1176
1177    pub fn with_api_listen(
1178        mut self,
1179        api_listen: std::net::SocketAddr,
1180    ) -> Result<Self, NodeServerError> {
1181        if !api_listen.ip().is_loopback() {
1182            return Err(NodeServerError::InvalidApiListen(api_listen));
1183        }
1184        self.api_listen = Some(api_listen);
1185        Ok(self)
1186    }
1187
1188    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1189    /// as `with_api_listen`). Does not enable by default — libraries and the
1190    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1191    pub fn with_bridge_listen(
1192        mut self,
1193        bridge_listen: std::net::SocketAddr,
1194    ) -> Result<Self, NodeServerError> {
1195        if !bridge_listen.ip().is_loopback() {
1196            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1197        }
1198        self.bridge_listen = Some(bridge_listen);
1199        Ok(self)
1200    }
1201
1202    /// Optional bridge shared secret, distinct from the C2/node access token.
1203    /// Never log the value. Empty / oversize refused.
1204    pub fn with_bridge_token(
1205        mut self,
1206        bridge_token: impl Into<String>,
1207    ) -> Result<Self, NodeServerError> {
1208        let bridge_token = bridge_token.into();
1209        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1210            return Err(NodeServerError::InvalidBridgeToken);
1211        }
1212        self.bridge_token = Some(bridge_token);
1213        Ok(self)
1214    }
1215
1216    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1217    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1218    /// already configured. Transport key is 32 raw bytes (never logged).
1219    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1220    pub fn with_bridge_underlay(
1221        mut self,
1222        bind: std::net::SocketAddr,
1223        transport_key: [u8; 32],
1224        underlay_token: impl Into<String>,
1225    ) -> Result<Self, NodeServerError> {
1226        if self.bridge_listen.is_none() {
1227            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1228        }
1229        if self.bridge_token.is_none() {
1230            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1231        }
1232        let underlay_token = underlay_token.into();
1233        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1234            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1235        }
1236        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1237        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1238            bind,
1239            transport_key,
1240            underlay_token,
1241        });
1242        Ok(self)
1243    }
1244
1245    /// Also dial `relay` and serve this wire over the connection, in
1246    /// addition to accepting on the local endpoint.
1247    ///
1248    /// Who opens the socket and who is the protocol's server are separate
1249    /// questions, and this is the node's half of separating them. The node
1250    /// stays the server either way -- it answers `NodeRequest`s and emits
1251    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1252    /// that changes is that a node with no address anyone can reach makes
1253    /// the connection itself, and names itself once it arrives (see
1254    /// `gate4agent_node_wire::write_call_home_announce`).
1255    ///
1256    /// Loopback only, and that is not an oversight to be flagged and
1257    /// worked around. This wire carries terminal contents, keystrokes and
1258    /// file bytes as plaintext JSON; its mutual challenge-response
1259    /// authenticates both ends and encrypts nothing. Every listener in
1260    /// this stack is loopback-guarded for that reason, and a dialler must
1261    /// hold the same line -- a node calling a public relay would put the
1262    /// whole control plane on the wire in the clear. Reaching a relay on
1263    /// another host is a transport question, and it stays unanswered here
1264    /// on purpose: this change is about direction, not distance.
1265    pub fn with_call_home(
1266        mut self,
1267        relay: std::net::SocketAddr,
1268    ) -> Result<Self, NodeServerError> {
1269        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1270        // and port 0 refused — plaintext wire must not dial every interface.
1271        if relay.port() == 0 || relay.ip().is_unspecified() {
1272            return Err(NodeServerError::InvalidCallHome(relay));
1273        }
1274        self.call_home = Some(relay);
1275        Ok(self)
1276    }
1277
1278    /// Authenticate call-home with a node-held identity instead of a standing token.
1279    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1280        self.call_home_identity = Some(identity);
1281        self
1282    }
1283
1284    pub fn with_state_path(
1285        mut self,
1286        state_path: impl Into<PathBuf>,
1287    ) -> Result<Self, NodeServerError> {
1288        let state_path = state_path.into();
1289        if !state_path.is_absolute() || state_path.file_name().is_none() {
1290            return Err(NodeServerError::InvalidStatePath(
1291                state_path.to_string_lossy().into_owned(),
1292            ));
1293        }
1294        self.state_path = Some(state_path);
1295        Ok(self)
1296    }
1297
1298    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1299        self.spawn_profiles = spawn_profiles;
1300        self
1301    }
1302
1303    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1304    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1305    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1306    /// when this is never called -- a node never deletes a durable record
1307    /// until an operator opts in with a real value.
1308    pub fn with_session_record_retention(
1309        mut self,
1310        retention: SessionRecordRetentionConfig,
1311    ) -> Self {
1312        self.session_record_retention = retention;
1313        self
1314    }
1315
1316    pub fn with_session_environment_materialization(
1317        mut self,
1318        root: impl Into<PathBuf>,
1319        resolver: Arc<dyn NodeSecretResolver>,
1320    ) -> Result<Self, NodeServerError> {
1321        let root = root.into();
1322        if !root.is_absolute() {
1323            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1324        }
1325        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1326        Ok(self)
1327    }
1328
1329    /// Enables bounded provider-history discovery and loading from explicit
1330    /// host-owned roots. Ambient vendor homes are never inferred here.
1331    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1332        self.history = Some(history);
1333        self
1334    }
1335
1336    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1337    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1338    /// Both platforms review the helper the same way (absolute regular file +
1339    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1340    /// gate after Unix listener/peer-cred support landed.
1341    pub fn with_harness_mcp_helper(
1342        mut self,
1343        helper_program: impl Into<PathBuf>,
1344    ) -> Result<Self, NodeServerError> {
1345        self.harness_mcp_helper = Some(
1346            ReviewedHarnessMcpProgram::review(helper_program.into())
1347                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1348        );
1349        Ok(self)
1350    }
1351
1352    /// Install an in-memory station network allowlist catalog (opaque ids +
1353    /// optional node-local permits / provider-native mapping). Empty remains
1354    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1355    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1356    pub fn with_network_allowlist_catalog(
1357        mut self,
1358        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1359    ) -> Result<Self, NodeServerError> {
1360        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1361            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1362                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1363            });
1364        }
1365        self.network_allowlist_catalog = catalog;
1366        Ok(self)
1367    }
1368
1369    /// Load station network allowlist catalog from an absolute regular file
1370    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1371    /// that want env fallback use `resolve_network_allowlist_catalog`.
1372    pub fn with_network_allowlist_catalog_file(
1373        self,
1374        path: impl AsRef<std::path::Path>,
1375    ) -> Result<Self, NodeServerError> {
1376        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1377            .map_err(NodeServerError::from)?;
1378        self.with_network_allowlist_catalog(catalog)
1379    }
1380}
1381
1382pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1383    platform::default_state_path(node_id.as_str())
1384        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1385}
1386
1387pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1388    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1389}
1390
1391fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1392    let parent = state_path.parent()?;
1393    let mut name = state_path.file_name()?.to_os_string();
1394    name.push(".delivery-store");
1395    Some(parent.join(name))
1396}
1397
1398fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1399    let parent = state_path.parent()?;
1400    let mut name = state_path.file_name()?.to_os_string();
1401    name.push(".context-pack-store");
1402    Some(parent.join(name))
1403}
1404
1405pub struct NodeServer {
1406    config: NodeServerConfig,
1407    runtime: NativeRuntime,
1408    shared: Arc<NodeShared>,
1409    events: EventSubscription,
1410    state_path_lock: Option<session_registry::StatePathLock>,
1411}
1412
1413#[cfg(feature = "fixture")]
1414#[derive(Clone, Default)]
1415pub struct SpawnManagedWorktreeV2FailureProbe {
1416    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1417}
1418
1419#[cfg(feature = "fixture")]
1420impl SpawnManagedWorktreeV2FailureProbe {
1421    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1422        *self
1423            .latest_code
1424            .lock()
1425            .unwrap_or_else(|poisoned| poisoned.into_inner())
1426    }
1427
1428    fn record(&self, code: Option<NodeFailureCode>) {
1429        *self
1430            .latest_code
1431            .lock()
1432            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1433    }
1434}
1435
1436#[cfg(all(feature = "fixture", windows))]
1437struct IsolatedCodexHomeFixtureEnvironment;
1438
1439#[cfg(all(feature = "fixture", windows))]
1440impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1441    for IsolatedCodexHomeFixtureEnvironment
1442{
1443    fn resolve_child_environment(
1444        &self,
1445    ) -> Result<
1446        Vec<gate4agent_catalog::EnvMutation>,
1447        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1448    > {
1449        Ok(vec![gate4agent_catalog::EnvMutation {
1450            key: OsString::from("USERPROFILE"),
1451            value: None,
1452        }])
1453    }
1454}
1455
1456impl NodeServer {
1457    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1458    /// must present.
1459    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1460        self.shared.m4a_accounts.as_ref()?;
1461        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1462    }
1463
1464    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1465        let catalog = active_registry()?;
1466        Self::new_with_registry(config, catalog)
1467    }
1468
1469    #[cfg(feature = "fixture")]
1470    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1471        let mut spec = gate4agent_testkit::interactive_agent_spec();
1472        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1473        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1474        Self::new_fixture_with_spec(config, spec)
1475    }
1476
1477    #[cfg(feature = "fixture")]
1478    pub fn new_clean_exit_fixture(
1479        config: NodeServerConfig,
1480        fixture_root: PathBuf,
1481        started_marker: PathBuf,
1482        release_signal: PathBuf,
1483    ) -> Result<Self, NodeServerError> {
1484        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1485            &fixture_root,
1486            &started_marker,
1487            &release_signal,
1488        )
1489        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490        spec.id = AgentId::new("claude")
1491            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1492        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1493        Self::new_fixture_with_spec(config, spec)
1494    }
1495
1496    /// A `claude` source whose session establishes a verified provider
1497    /// identity via an early `SessionStart` Hook post (so it is eligible for
1498    /// auto-export-at-exit and its native history is independently
1499    /// loadable), waits for `release_signal`, then exits cleanly — paired
1500    /// on the same Node with a plain `codex` target that never validates
1501    /// anything about what it receives. Built for A2 durable ContextPack
1502    /// E2E coverage: the test itself inspects materialized files and
1503    /// managed-session-record fields directly, rather than having the
1504    /// target script self-validate.
1505    #[cfg(all(feature = "fixture", windows))]
1506    pub fn new_durable_context_pack_clean_exit_fixture(
1507        config: NodeServerConfig,
1508        fixture_root: PathBuf,
1509        started_marker: PathBuf,
1510        release_signal: PathBuf,
1511        session_id: &str,
1512    ) -> Result<Self, NodeServerError> {
1513        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1514            &fixture_root,
1515            &started_marker,
1516            &release_signal,
1517            session_id,
1518        )
1519        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1520        let source_id = AgentId::new("claude")
1521            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1522        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1523            NodeServerError::Registry(
1524                "durable context pack clean-exit fixture source adapter is unavailable"
1525                    .to_owned(),
1526            )
1527        })?;
1528        source_spec.capabilities.adapters.history =
1529            source_provider.capabilities.adapters.history.clone();
1530        source_spec.id = source_id;
1531        source_spec.display_name =
1532            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1533        // Both specs launch via `powershell.exe`; the registry's ambiguity
1534        // check keys on `detection.command` (+ aliases), not `launch.program`
1535        // -- give each a distinct detection identity, same precedent as
1536        // `context_pack_fixture_catalog`'s per-provider
1537        // `gate4agent-{provider}-context-fixture.cmd` commands.
1538        source_spec.detection.command =
1539            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1540        source_spec.detection.aliases.clear();
1541
1542        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1543        target_spec.id = AgentId::new("codex")
1544            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1545        target_spec.display_name =
1546            "Controlled Codex durable context-pack target fixture".to_owned();
1547        target_spec.detection.command =
1548            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1549        target_spec.detection.aliases.clear();
1550
1551        let catalog = AgentRegistry::new([source_spec, target_spec])
1552            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let mut server = Self::new_with_registry(config, catalog)?;
1554        Arc::get_mut(&mut server.shared)
1555            .expect("fixture Node shared state must remain uniquely owned before run")
1556            .fixture_semantic_hook_policy = true;
1557        Ok(server)
1558    }
1559
1560    #[cfg(feature = "fixture")]
1561    pub fn new_monitoring_hook_fixture(
1562        config: NodeServerConfig,
1563    ) -> Result<Self, NodeServerError> {
1564        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1565        spec.id = AgentId::new("claude")
1566            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567        let mut server = Self::new_fixture_with_spec(config, spec)?;
1568        Arc::get_mut(&mut server.shared)
1569            .expect("fixture Node shared state must remain uniquely owned before run")
1570            .fixture_semantic_hook_policy = true;
1571        Ok(server)
1572    }
1573
1574    #[cfg(all(feature = "fixture", windows))]
1575    pub fn new_provider_bundle_argv_fixture(
1576        config: NodeServerConfig,
1577        agent_id: AgentId,
1578        proof_path: PathBuf,
1579    ) -> Result<Self, NodeServerError> {
1580        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1581        Self::new_fixture_with_spec(config, spec)
1582    }
1583
1584    #[cfg(all(feature = "fixture", windows))]
1585    pub fn new_provider_bundle_argv_hold_fixture(
1586        config: NodeServerConfig,
1587        agent_id: AgentId,
1588        proof_path: PathBuf,
1589        release_signal: PathBuf,
1590    ) -> Result<Self, NodeServerError> {
1591        let spec = Self::provider_bundle_argv_fixture_spec(
1592            agent_id,
1593            proof_path,
1594            Some(release_signal),
1595        )?;
1596        Self::new_fixture_with_spec(config, spec)
1597    }
1598
1599    #[cfg(all(feature = "fixture", windows))]
1600    fn provider_bundle_argv_fixture_spec(
1601        agent_id: AgentId,
1602        proof_path: PathBuf,
1603        release_signal: Option<PathBuf>,
1604    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1605        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1606            return Err(NodeServerError::Registry(
1607                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1608            ));
1609        }
1610        let mut spec = gate4agent_testkit::interactive_agent_spec();
1611        spec.id = agent_id.clone();
1612        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1613        let provider = builtin_registry()
1614            .get(&agent_id)
1615            .ok_or_else(|| {
1616                NodeServerError::Registry(
1617                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1618                )
1619            })?;
1620        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1621        if !proof_path.is_absolute()
1622            || !proof_path
1623                .parent()
1624                .is_some_and(|parent| parent.is_dir())
1625        {
1626            return Err(NodeServerError::Registry(
1627                "provider bundle argv proof path requires an existing absolute parent"
1628                    .to_owned(),
1629            ));
1630        }
1631        if let Some(release_signal) = release_signal.as_ref() {
1632            if !release_signal.is_absolute()
1633                || !release_signal
1634                    .parent()
1635                    .is_some_and(|parent| parent.is_dir())
1636            {
1637                return Err(NodeServerError::Registry(
1638                    "provider bundle argv release path requires an existing absolute parent"
1639                        .to_owned(),
1640                ));
1641            }
1642            if release_signal.exists() {
1643                return Err(NodeServerError::Registry(
1644                    "provider bundle argv release path must not exist".to_owned(),
1645                ));
1646            }
1647            let proof_parent = std::fs::canonicalize(
1648                proof_path
1649                    .parent()
1650                    .expect("validated provider bundle proof parent"),
1651            )
1652            .map_err(|_| {
1653                NodeServerError::Registry(
1654                    "provider bundle argv proof parent is unavailable".to_owned(),
1655                )
1656            })?;
1657            let release_parent = std::fs::canonicalize(
1658                release_signal
1659                    .parent()
1660                    .expect("validated provider bundle release parent"),
1661            )
1662            .map_err(|_| {
1663                NodeServerError::Registry(
1664                    "provider bundle argv release parent is unavailable".to_owned(),
1665                )
1666            })?;
1667            if proof_parent != release_parent {
1668                return Err(NodeServerError::Registry(
1669                    "provider bundle argv release path must share the proof parent".to_owned(),
1670                ));
1671            }
1672        }
1673        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1674            NodeServerError::Registry(
1675                "provider bundle argv proof path is not valid Unicode".to_owned(),
1676            )
1677        })?;
1678        let release_signal = release_signal.map(|release_signal| {
1679            release_signal.into_os_string().into_string().map_err(|_| {
1680                NodeServerError::Registry(
1681                    "provider bundle argv release path is not valid Unicode".to_owned(),
1682                )
1683            })
1684        }).transpose()?;
1685        let script = spec
1686            .launch
1687            .fixed_args
1688            .pop()
1689            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1690        let bundle_validation = if agent_id.as_str() == "codex" {
1691            "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1692        } else if agent_id.as_str() == "claude" {
1693            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1694        } else {
1695            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1696        };
1697        if let Some(release_signal) = release_signal {
1698            spec.launch.fixed_args.push(format!(
1699                "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1700            ));
1701            spec.launch.fixed_args.push(proof_path);
1702            spec.launch.fixed_args.push(release_signal);
1703        } else {
1704            spec.launch.fixed_args.push(format!(
1705                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1706            ));
1707            spec.launch.fixed_args.push(proof_path);
1708        }
1709        Ok(spec)
1710    }
1711
1712    #[cfg(all(feature = "fixture", windows))]
1713    pub fn new_context_pack_fixture(
1714        config: NodeServerConfig,
1715        proof_path: PathBuf,
1716    ) -> Result<Self, NodeServerError> {
1717        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1718        Self::new_with_registry(config, catalog)
1719    }
1720
1721    #[cfg(all(feature = "fixture", windows))]
1722    pub fn new_monitoring_context_pack_fixture(
1723        config: NodeServerConfig,
1724        proof_path: PathBuf,
1725    ) -> Result<Self, NodeServerError> {
1726        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1727        let mut server = Self::new_with_registry(config, catalog)?;
1728        Arc::get_mut(&mut server.shared)
1729            .expect("fixture Node shared state must remain uniquely owned before run")
1730            .fixture_semantic_hook_policy = true;
1731        Ok(server)
1732    }
1733
1734    #[cfg(all(feature = "fixture", windows))]
1735    pub fn install_isolated_codex_home_environment_fixture(
1736        &mut self,
1737        profile_id: SpawnEnvironmentProfileId,
1738        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1739    ) -> Result<(), NodeServerError> {
1740        let provider = AgentId::new("codex")
1741            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1742        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1743            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1744                "{}-pty",
1745                profile_id.as_str(),
1746            ))
1747            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1748            provider.clone(),
1749            TransportKind::Pty,
1750            vec![OsString::from("USERPROFILE")],
1751            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1752        )
1753        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1754        let materialization = NodeSessionMaterializationProfile::new(
1755            Vec::new(),
1756            vec![crate::session_environment::NodeSessionPathBinding::new(
1757                "CODEX_HOME",
1758                crate::session_environment::NodeSessionPathClass::ProviderHome,
1759            )
1760            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1761            Vec::new(),
1762        )
1763        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1764        self.install_environment_profile(
1765            NodeEnvironmentProfile::new_with_materialization(
1766                profile_id,
1767                profile_revision,
1768                provider,
1769                [native_profile],
1770                Some(materialization),
1771            )
1772            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1773        )
1774    }
1775
1776    #[cfg(all(feature = "fixture", windows))]
1777    pub fn new_context_only_proof_fixture(
1778        config: NodeServerConfig,
1779        target_provider: AgentId,
1780        proof_path: PathBuf,
1781    ) -> Result<Self, NodeServerError> {
1782        if target_provider.as_str() != "kimi" {
1783            return Err(NodeServerError::Registry(
1784                "context-only proof fixture requires Kimi".to_owned(),
1785            ));
1786        }
1787        let catalog = Self::context_pack_fixture_catalog(
1788            proof_path,
1789            Some(&target_provider),
1790            false,
1791        )?;
1792        Self::new_with_registry(config, catalog)
1793    }
1794
1795    #[cfg(all(feature = "fixture", windows))]
1796    fn context_pack_fixture_catalog(
1797        proof_path: PathBuf,
1798        context_only_target: Option<&AgentId>,
1799        monitoring_claude_source: bool,
1800    ) -> Result<AgentRegistry, NodeServerError> {
1801        if !proof_path.is_absolute()
1802            || !proof_path
1803                .parent()
1804                .is_some_and(|parent| parent.is_dir())
1805        {
1806            return Err(NodeServerError::Registry(
1807                "context pack proof path requires an existing absolute parent".to_owned(),
1808            ));
1809        }
1810        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1811            NodeServerError::Registry(
1812                "context pack proof path is not valid Unicode".to_owned(),
1813            )
1814        })?;
1815        let providers = ["claude", "codex", "grok", "kimi"];
1816        let builtins = builtin_registry();
1817        let mut specs = Vec::with_capacity(providers.len());
1818        for provider_id in providers {
1819            let agent_id = AgentId::new(provider_id)
1820                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1821            let provider = builtins.get(&agent_id).ok_or_else(|| {
1822                NodeServerError::Registry(
1823                    "context pack fixture provider adapter is unavailable".to_owned(),
1824                )
1825            })?;
1826            let monitored_provider =
1827                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1828            let mut spec = if monitored_provider {
1829                gate4agent_testkit::monitoring_hook_agent_spec()
1830            } else {
1831                gate4agent_testkit::interactive_agent_spec()
1832            };
1833            spec.id = agent_id.clone();
1834            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1835            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1836            spec.detection.aliases.clear();
1837            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1838            if provider_id == "codex" {
1839                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1840                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1841                })?;
1842                let validation = r#"
1843$bundleArgs = @($bundleArgs)
1844if ($bundleArgs.Count -ne 0) { exit 91 }
1845$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1846$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1847$cwd = (Get-Location).ProviderPath
1848if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1849$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1850if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1851$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1852if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1853$sha = [Security.Cryptography.SHA256]::Create()
1854try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1855if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1856if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1857$contextPath = Join-Path $contextRoot 'context-pack.json'
1858if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1859$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1860if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1861$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1862$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1863$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1864$separator = [IO.Path]::DirectorySeparatorChar
1865if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1866if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1867$rawContext = [IO.File]::ReadAllText($contextPath)
1868if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1869try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1870$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1871$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1872foreach ($property in @($document.PSObject.Properties.Name)) {
1873    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1874}
1875foreach ($property in $requiredTopLevel) {
1876    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1877}
1878if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1879if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1880$messages = @($document.retained_messages)
1881if ($messages.Count -eq 0) { exit 107 }
1882foreach ($message in $messages) {
1883    $messageProperties = @($message.PSObject.Properties.Name)
1884    foreach ($property in $messageProperties) {
1885        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1886    }
1887    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1888}
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1892$rawProof = [IO.File]::ReadAllText($proofPath)
1893if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1894[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1895"#;
1896                spec.launch.fixed_args.push(format!(
1897                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1898                ));
1899                spec.launch.fixed_args.push(proof_path.clone());
1900            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1901                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1902                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1903                })?;
1904                let validation = r#"
1905$bundleArgs = @($bundleArgs)
1906if ($bundleArgs.Count -ne 0) { exit 91 }
1907$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1908$cwd = (Get-Location).ProviderPath
1909if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1910$contextPath = Join-Path $contextRoot 'context-pack.json'
1911if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1912$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1913if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1914$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1915$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1916$separator = [IO.Path]::DirectorySeparatorChar
1917if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1918try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1919if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1920if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1921$messages = @($document.retained_messages)
1922if ($messages.Count -eq 0) { exit 99 }
1923$hasUser = $false
1924$hasAssistant = $false
1925foreach ($message in $messages) {
1926    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1927    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1928    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1929    else { exit 101 }
1930}
1931if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1932$sha = [Security.Cryptography.SHA256]::Create()
1933try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1934[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1935[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1936"#;
1937                spec.launch.fixed_args.push(format!(
1938                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1939                ));
1940                spec.launch.fixed_args.push(proof_path.clone());
1941            }
1942            specs.push(spec);
1943        }
1944        let catalog = AgentRegistry::new(specs)
1945            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1946        Ok(catalog)
1947    }
1948
1949    #[cfg(feature = "fixture")]
1950    pub fn new_exact_launcher_fixture(
1951        config: NodeServerConfig,
1952        agent_id: AgentId,
1953        launcher: String,
1954    ) -> Result<Self, NodeServerError> {
1955        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1956            return Err(NodeServerError::Registry(
1957                "exact launcher fixture requires a supported provider".to_owned(),
1958            ));
1959        }
1960        if launcher.contains('\0') {
1961            return Err(NodeServerError::Registry(
1962                "exact launcher fixture path contains NUL".to_owned(),
1963            ));
1964        }
1965        let launcher_path = Path::new(&launcher);
1966        if !launcher_path.is_absolute() {
1967            return Err(NodeServerError::Registry(
1968                "exact launcher fixture path must be absolute".to_owned(),
1969            ));
1970        }
1971        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1972            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1973        })?;
1974        if !metadata.is_file() {
1975            return Err(NodeServerError::Registry(
1976                "exact launcher fixture path must be a regular file".to_owned(),
1977            ));
1978        }
1979        let mut spec = builtin_registry()
1980            .get(&agent_id)
1981            .ok_or_else(|| {
1982                NodeServerError::Registry(
1983                    "exact launcher fixture provider is unavailable".to_owned(),
1984                )
1985            })?
1986            .clone();
1987        spec.launch.program = launcher;
1988        Self::new_fixture_with_spec(config, spec)
1989    }
1990
1991    /// Shared H3B validation and spec construction for
1992    /// `new_harness_mcp_proxy_fixture` and
1993    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1994    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1995    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1996    /// the named builtin catalog entry -- detection command and expected
1997    /// process matcher rebased on the launcher's own basename, exactly the
1998    /// same shape `new_harness_mcp_proxy_fixture` always built.
1999    #[cfg(feature = "fixture")]
2000    fn h3b_provider_spec_with_program_and_args(
2001        agent_id: AgentId,
2002        provider_program: PathBuf,
2003        fixed_args: Vec<OsString>,
2004    ) -> Result<AgentSpec, NodeServerError> {
2005        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2006            return Err(NodeServerError::Registry(
2007                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2008            ));
2009        }
2010        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2011            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2012        })?;
2013        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2014            return Err(NodeServerError::Registry(
2015                "H3B fixture provider must be an exact regular file".to_owned(),
2016            ));
2017        }
2018        let program = provider_program.into_os_string().into_string().map_err(|_| {
2019            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2020        })?;
2021        let fixed_args = fixed_args.into_iter().map(|argument| {
2022            argument.into_string().map_err(|_| NodeServerError::Registry(
2023                "H3B fixture argument is not Unicode".to_owned(),
2024            ))
2025        }).collect::<Result<Vec<_>, _>>()?;
2026        let argument_bytes = fixed_args.iter()
2027            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2028        if program.contains('\0')
2029            || fixed_args.iter().any(|argument| argument.contains('\0'))
2030            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2031        {
2032            return Err(NodeServerError::Registry(
2033                "H3B fixture provider or arguments are invalid".to_owned(),
2034            ));
2035        }
2036        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2037            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2038        })?.clone();
2039        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2040            .ok_or_else(|| NodeServerError::Registry(
2041                "H3B fixture provider basename is unavailable".to_owned(),
2042            ))?.to_owned();
2043        spec.detection.command = process_name.clone();
2044        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2045            name: process_name,
2046        }];
2047        spec.launch.program = program;
2048        spec.launch.fixed_args = fixed_args;
2049        Ok(spec)
2050    }
2051
2052    #[cfg(feature = "fixture")]
2053    pub fn new_harness_mcp_proxy_fixture(
2054        config: NodeServerConfig,
2055        provider_program: PathBuf,
2056        fixed_args: Vec<OsString>,
2057        harness_mcp_program: PathBuf,
2058    ) -> Result<Self, NodeServerError> {
2059        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2060        let agent_id = AgentId::new("codex")
2061            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2062        let mut spec =
2063            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2064        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2065        config.fixture_raw_pty_runtime = true;
2066        Self::new_fixture_with_spec(config, spec)
2067    }
2068
2069    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2070    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2071    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2072    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2073    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2074    /// fixture_raw_pty_runtime = true`, which tears out the live
2075    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2076    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2077    /// hardcode `acp_transport = false` for every provider, refusing
2078    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2079    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2080    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2081    /// is built from the (cloned, launcher-replaced) catalog spec, and
2082    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2083    /// unmodified catalog fact: every builtin provider this accepts
2084    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2085    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2086    /// invented here -- this constructor only removes the fixture-only
2087    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2088    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2089    /// for every ACP session, fixture or not) is what actually grants the
2090    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2091    /// this constructor never calls it directly, and does not need to.
2092    #[cfg(feature = "fixture")]
2093    pub fn new_harness_mcp_acp_launcher_fixture(
2094        config: NodeServerConfig,
2095        helper_program: PathBuf,
2096        provider: AgentId,
2097        program: PathBuf,
2098        args: Vec<OsString>,
2099    ) -> Result<Self, NodeServerError> {
2100        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2101            return Err(NodeServerError::Registry(
2102                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2103            ));
2104        }
2105        let config = config.with_harness_mcp_helper(helper_program)?;
2106        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2107        let launch = spec.launch.clone();
2108        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2109            NodeServerError::Registry(
2110                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2111                    .to_owned(),
2112            )
2113        })?;
2114        // Routes the ACP spawn itself through the exact replaced launcher
2115        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2116        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2117        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2118        // without this the ACP transport ignores `spec.launch` entirely and
2119        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2120        // codex-acp@1.10.0`), which this fixture must never reach.
2121        acp.launch_override = Some(launch);
2122        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2123        Self::new_fixture_with_spec(config, spec)
2124    }
2125
2126    #[cfg(feature = "fixture")]
2127    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2128        let mut spec = gate4agent_testkit::interactive_agent_spec();
2129        let claude_id = AgentId::new("claude")
2130            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2131        let claude = builtin_registry()
2132            .get(&claude_id)
2133            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2134        spec.id = claude_id;
2135        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2136        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2137            name: "claude".to_owned(),
2138        }];
2139        spec.capabilities.transports.pty_adapter = claude
2140            .capabilities
2141            .transports
2142            .pty_adapter
2143            .clone();
2144        spec.capabilities.adapters.resume = claude
2145            .capabilities
2146            .adapters
2147            .resume
2148            .clone();
2149        let script = spec
2150            .launch
2151            .fixed_args
2152            .pop()
2153            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2154        spec.launch.fixed_args.push(format!(
2155            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2156        ));
2157        Self::new_fixture_with_spec(config, spec)
2158    }
2159
2160    #[cfg(feature = "fixture")]
2161    pub fn new_cmd_cwd_fixture(
2162        config: NodeServerConfig,
2163        command: String,
2164    ) -> Result<Self, NodeServerError> {
2165        let mut spec = gate4agent_testkit::interactive_agent_spec();
2166        spec.id = AgentId::new("claude")
2167            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2168        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2169        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2170        spec.launch.program = command;
2171        spec.launch.fixed_args.clear();
2172        Self::new_fixture_with_spec(config, spec)
2173    }
2174
2175    #[cfg(feature = "fixture")]
2176    fn new_fixture_with_spec(
2177        config: NodeServerConfig,
2178        spec: gate4agent_types::AgentSpec,
2179    ) -> Result<Self, NodeServerError> {
2180        let catalog = AgentRegistry::new([spec])
2181            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2182        Self::new_with_registry(config, catalog)
2183    }
2184
2185    fn new_with_registry(
2186        config: NodeServerConfig,
2187        catalog: AgentRegistry,
2188    ) -> Result<Self, NodeServerError> {
2189        for profile in config.spawn_profiles.iter() {
2190            if catalog.get(&profile.provider).is_none() {
2191                return Err(NodeServerError::Registry(
2192                    "spawn profile references an unavailable provider".to_owned(),
2193                ));
2194            }
2195        }
2196        let input_settle_timeout_ms = catalog
2197            .iter()
2198            .map(|spec| spec.readiness.timeout_ms)
2199            .max()
2200            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2201            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2202            .max(MUTATION_SETTLE_TIMEOUT_MS);
2203        let (provider_contracts, provider_adapter_contracts) =
2204            provider_contract_manifest(&catalog)?;
2205        let compatibility_support = node_compatibility_support_for_manifest(
2206            &provider_contracts,
2207            &provider_adapter_contracts,
2208        )?;
2209        validate_node_negotiated_handshake_capacity(&compatibility_support)
2210            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2211        let enabled_providers = provider_contracts
2212            .iter()
2213            .map(|contract| contract.provider.clone())
2214            .collect();
2215        #[cfg(feature = "fixture")]
2216        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2217        #[cfg(not(feature = "fixture"))]
2218        let fixture_raw_pty_runtime = false;
2219        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2220            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2221                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2222            })).expect("fixture provider catalog remains bounded");
2223            (None, statuses)
2224        } else {
2225            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2226            let statuses = monitor.collect();
2227            (Some(monitor), statuses)
2228        };
2229        let state_path_lock =
2230            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2231                .map_err(|error| {
2232                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2233                })?;
2234        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2235            Some(state_path) => {
2236                let delivery_root = delivery_store_root_for_state_path(state_path)
2237                    .ok_or_else(|| {
2238                        NodeServerError::DeliveryStore(
2239                            "delivery store root could not be derived from state path".to_owned(),
2240                        )
2241                    })?;
2242                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2243                    let detail = match &error {
2244                        DeliveryStoreError::Storage(io_error) => {
2245                            format!("{error}: {io_error}")
2246                        }
2247                        _ => error.to_string(),
2248                    };
2249                    NodeServerError::DeliveryStore(detail)
2250                })?;
2251                (Some(store), bundles)
2252            }
2253            None => (None, Vec::new()),
2254        };
2255        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2256            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2257        })?;
2258        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2259            Some(state_path) => {
2260                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2261                    .ok_or(NodeServerError::ContextPackStore)?;
2262                let (store, packs) = ContextPackStore::open(context_pack_root)
2263                    .map_err(|_| NodeServerError::ContextPackStore)?;
2264                (Some(store), packs)
2265            }
2266            None => (None, Vec::new()),
2267        };
2268        let mut durable_context_catalog = ContextPackCatalog::default();
2269        for pack in durable_context_packs {
2270            durable_context_catalog
2271                .insert(pack)
2272                .map_err(|_| NodeServerError::ContextPackStore)?;
2273        }
2274        let session_environment_materializer = config
2275            .session_environment
2276            .as_ref()
2277            .map(|environment| {
2278                SessionEnvironmentMaterializer::new(
2279                    environment.root.clone(),
2280                    Arc::clone(&environment.resolver),
2281                )
2282                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2283            })
2284            .transpose()?;
2285        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2286        // (the provider version probe above already used the original catalog).
2287        #[cfg(unix)]
2288        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2289        let native_session_catalog = config.history.clone().map(|history| {
2290            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2291        });
2292        let (handle, runtime) = match config.history.clone() {
2293            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2294            None => NativeRuntime::new(catalog, config.runtime),
2295        };
2296        let native_launch_profile_control = runtime.native_launch_profile_control();
2297        let harness_mcp_registry = config
2298            .harness_mcp_helper
2299            .clone()
2300            .map(HarnessMcpProxyRegistry::new);
2301        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2302        let incarnation_id = random_incarnation_id()
2303            .map_err(NodeServerError::IncarnationIdentity)?;
2304        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2305            .map_err(durable_state_load_error)?;
2306        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2307        let managed_worktree_tombstones =
2308            std::mem::take(&mut loaded.managed_worktree_tombstones);
2309        let materializations = std::mem::take(&mut loaded.materializations);
2310        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2311        let (workspaces, records, persistence_warning) =
2312            merge_durable_state(&config.workspaces, loaded)?;
2313        let mut shared = NodeShared::new_with_incarnation(
2314            handle,
2315            config.access_token.clone(),
2316            config.node_id.clone(),
2317            incarnation_id,
2318            workspaces,
2319            enabled_providers,
2320            provider_runtime_statuses,
2321            provider_runtime_monitor,
2322            provider_contracts,
2323            provider_adapter_contracts,
2324            config.spawn_profiles.clone(),
2325            Some(native_launch_profile_control),
2326            native_session_catalog,
2327            session_environment_materializer,
2328            config.state_path.clone(),
2329            records,
2330            managed_worktrees,
2331            managed_worktree_tombstones,
2332            materializations,
2333            persistence_warning,
2334            input_settle_timeout_ms,
2335        );
2336        shared.harness_mcp_registry = harness_mcp_registry.clone();
2337        shared.session_record_retention = config.session_record_retention;
2338        shared.network_allowlist_catalog =
2339            RwLock::new(config.network_allowlist_catalog.clone());
2340        shared.bundle_catalog = RwLock::new(delivered_catalog);
2341        shared.delivery_store = Mutex::new(delivery_store);
2342        shared.context_catalog = RwLock::new(durable_context_catalog);
2343        shared.context_pack_store = Mutex::new(context_pack_store);
2344        shared.managed_spawn_replays = Mutex::new(
2345            managed_spawn_replays
2346                .into_iter()
2347                .map(|record| (record.idempotency_key.clone(), record))
2348                .collect(),
2349        );
2350        let shared = Arc::new(shared);
2351        if let Some(registry) = harness_mcp_registry {
2352            let weak = Arc::downgrade(&shared);
2353            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2354                Some(shared) => shared.publish_transient(event),
2355                None => false,
2356            }));
2357        }
2358        if shared.persistence_error().is_none() {
2359            shared.persist_state().map_err(|error| {
2360                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2361            })?;
2362        }
2363        Ok(Self {
2364            config,
2365            runtime,
2366            shared,
2367            events,
2368            state_path_lock,
2369        })
2370    }
2371
2372    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2373        NodeShutdownHandle {
2374            shared: Arc::clone(&self.shared),
2375        }
2376    }
2377
2378    #[cfg(feature = "fixture")]
2379    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2380        &self,
2381    ) -> SpawnManagedWorktreeV2FailureProbe {
2382        self.shared
2383            .fixture_spawn_managed_worktree_v2_failure_probe
2384            .clone()
2385    }
2386
2387    /// Installs one immutable host-local environment profile before the server runs.
2388    ///
2389    /// Native resolvers remain inside the runtime; the shared node registry keeps
2390    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2391    pub fn install_environment_profile(
2392        &mut self,
2393        profile: NodeEnvironmentProfile,
2394    ) -> Result<(), NodeServerError> {
2395        if !self.shared.enabled_providers.contains(profile.provider()) {
2396            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2397                profile.provider().clone(),
2398            ));
2399        }
2400        let (binding, native_profiles, materialization) = profile.into_parts();
2401        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2402            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2403        }
2404        let mut profiles = self
2405            .shared
2406            .environment_profiles
2407            .write()
2408            .unwrap_or_else(|poisoned| poisoned.into_inner());
2409        if profiles.contains_key(&binding.id) {
2410            return Err(NodeServerError::DuplicateEnvironmentProfile(
2411                binding.id.clone(),
2412            ));
2413        }
2414        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2415            return Err(NodeServerError::EnvironmentProfileCapacity {
2416                max: MAX_NODE_ENVIRONMENT_PROFILES,
2417            });
2418        }
2419        for native_id in binding.native_profile_ids() {
2420            if profiles.values().any(|current| {
2421                current.native_profile_ids().any(|current_id| current_id == native_id)
2422            }) {
2423                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2424                    native_id.to_string(),
2425                ));
2426            }
2427        }
2428
2429        let mut installed = Vec::with_capacity(native_profiles.len());
2430        for native_profile in native_profiles {
2431            let native_id = native_profile.id().clone();
2432            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2433                for installed_id in installed.into_iter().rev() {
2434                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2435                }
2436                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2437            }
2438            installed.push(native_id);
2439        }
2440        if let Some(materialization) = materialization {
2441            self.shared
2442                .environment_materialization_profiles
2443                .write()
2444                .unwrap_or_else(|poisoned| poisoned.into_inner())
2445                .insert(binding.id.clone(), materialization);
2446        }
2447        profiles.insert(binding.id.clone(), binding);
2448        Ok(())
2449    }
2450
2451    /// Installs one immutable host-local bundle before the server runs.
2452    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2453        if self.shared.session_environment_materializer.is_none() {
2454            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2455        }
2456        let mut catalog = self
2457            .shared
2458            .bundle_catalog
2459            .write()
2460            .unwrap_or_else(|poisoned| poisoned.into_inner());
2461        catalog
2462            .insert_idempotent(bundle)
2463            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2464        Ok(())
2465    }
2466
2467    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2468        let shutdown = self.shutdown_handle();
2469        let run = self.run();
2470        tokio::pin!(run);
2471        tokio::select! {
2472            result = &mut run => result,
2473            signal = wait_for_ctrl_signal() => {
2474                signal?;
2475                let shutdown_result = shutdown.request_shutdown().await;
2476                let run_result = run.await;
2477                shutdown_result.and(run_result)
2478            }
2479        }
2480    }
2481
2482    pub async fn run(self) -> Result<(), NodeServerError> {
2483        let NodeServer {
2484            config,
2485            mut runtime,
2486            shared,
2487            events,
2488            state_path_lock: _state_path_lock,
2489        } = self;
2490        shared.reconcile_materializations();
2491        shared.reconcile_managed_worktrees().await;
2492        shared.retire_unavailable_session_records().await;
2493        shared.reconcile_context_pack_exports().await;
2494        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2495        // observes sessions through ACP where a provider has it; there is no
2496        // hook ingress.
2497        let endpoint = config.endpoint.clone();
2498        let call_home = config.call_home;
2499        let call_home_identity = config.call_home_identity.clone();
2500        let api_listen = config.api_listen;
2501        let bridge_listen = config.bridge_listen;
2502        let bridge_underlay = config.bridge_underlay;
2503        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2504            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2505        let accept_shared = Arc::clone(&shared);
2506        let api_shared = Arc::clone(&shared);
2507        let bridge_shared = Arc::clone(&shared);
2508        let shutdown_shared = Arc::clone(&shared);
2509        let shutdown_timeout = Duration::from_millis(
2510            config.runtime.provider_shutdown_timeout_ms.max(1),
2511        );
2512        let result = {
2513            let runtime_loop = drive_runtime_until_shutdown(
2514                &mut runtime,
2515                events,
2516                Arc::clone(&shared),
2517                shutdown_timeout,
2518            );
2519            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2520            let api_loop = http_api::run(api_listen, api_shared);
2521            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2522            tokio::pin!(runtime_loop);
2523            tokio::pin!(accept_loop);
2524            tokio::pin!(api_loop);
2525            tokio::pin!(bridge_loop);
2526            tokio::select! {
2527                runtime_result = &mut runtime_loop => {
2528                    let shutdown_result = shutdown_shared
2529                        .begin_shutdown()
2530                        .await
2531                        .map_err(NodeServerError::ShutdownDispatch);
2532                    let (accept_result, api_result, bridge_result) =
2533                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2534                    match runtime_result {
2535                        Err(error) => Err(error),
2536                        Ok(()) => shutdown_result
2537                            .and(accept_result)
2538                            .and(api_result.map_err(NodeServerError::HttpApi))
2539                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2540                    }
2541                }
2542                accept_result = &mut accept_loop => {
2543                    let shutdown_result = shutdown_shared
2544                        .begin_shutdown()
2545                        .await
2546                        .map_err(NodeServerError::ShutdownDispatch);
2547                    let (runtime_result, api_result, bridge_result) =
2548                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2549                    match accept_result {
2550                        Err(error) => Err(error),
2551                        Ok(()) => shutdown_result
2552                            .and(runtime_result)
2553                            .and(api_result.map_err(NodeServerError::HttpApi))
2554                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2555                    }
2556                }
2557                api_result = &mut api_loop => {
2558                    let shutdown_result = shutdown_shared
2559                        .begin_shutdown()
2560                        .await
2561                        .map_err(NodeServerError::ShutdownDispatch);
2562                    let (runtime_result, accept_result, bridge_result) =
2563                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2564                    match api_result {
2565                        Err(error) => Err(NodeServerError::HttpApi(error)),
2566                        Ok(()) => shutdown_result
2567                            .and(runtime_result)
2568                            .and(accept_result)
2569                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2570                    }
2571                }
2572                bridge_result = &mut bridge_loop => {
2573                    let shutdown_result = shutdown_shared
2574                        .begin_shutdown()
2575                        .await
2576                        .map_err(NodeServerError::ShutdownDispatch);
2577                    let (runtime_result, accept_result, api_result) =
2578                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2579                    match bridge_result {
2580                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2581                        Ok(()) => shutdown_result
2582                            .and(runtime_result)
2583                            .and(accept_result)
2584                            .and(api_result.map_err(NodeServerError::HttpApi)),
2585                    }
2586                }
2587            }
2588        };
2589        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2590        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2591        // NodeShutdownHandle also clones that Arc, so without an explicit release
2592        // here a supervisor that keeps the shutdown handle cannot reopen the same
2593        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2594        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2595        if let Ok(mut guard) = shared.delivery_store.lock() {
2596            *guard = None;
2597        }
2598        if let Ok(mut guard) = shared.context_pack_store.lock() {
2599            *guard = None;
2600        }
2601        result
2602    }
2603}
2604
2605async fn drive_runtime_until_shutdown(
2606    runtime: &mut NativeRuntime,
2607    events: EventSubscription,
2608    shared: Arc<NodeShared>,
2609    shutdown_timeout: Duration,
2610) -> Result<(), NodeServerError> {
2611    // Published once, never per iteration. The worker loops inside
2612    // `NativeRuntime` fold into this profile continuously; turning it into
2613    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2614    let _ = shared
2615        .shell_efficiency_profile
2616        .set(runtime.shell_efficiency_profile());
2617    let mut shutdown_started = None;
2618    let mut last_retention_sweep = Instant::now();
2619    let mut last_provisional_reap = Instant::now();
2620    loop {
2621        // Every phase below is timed unconditionally -- see
2622        // `drive_loop_profile`'s own doc comment for why an always-on,
2623        // allocation-free ring is cheap enough to never need a feature
2624        // flag. This is the only place these numbers can be taken: the
2625        // phases are calls made directly from this loop, not something a
2626        // callee could time on our behalf.
2627        let runtime_tick_start = Instant::now();
2628        let tick_result = runtime.tick().await;
2629        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2630
2631        let event_drain_start = Instant::now();
2632        // Every command the kernel refused, named. `NativeRuntimeTick`
2633        // has carried these outcomes all along and nothing read them, so a
2634        // rejected command -- a hook event denied by runtime policy, a
2635        // stale generation, an unsupported capability -- vanished at the
2636        // last step of the loop that produced it. A refusal the operator
2637        // cannot see is indistinguishable from an event that never arrived,
2638        // and telling those two apart is most of debugging this stack.
2639        for outcome in &tick_result.command_outcomes {
2640            if let Err(error) = &outcome.result {
2641                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2642                tracing::warn!(
2643                    command_id = outcome.command_id.0,
2644                    cause = %error,
2645                    "control command rejected"
2646                );
2647                shared.record_rejected_command(
2648                    outcome.command_id,
2649                    error.to_string(),
2650                    error.is_unsupported_transport(),
2651                );
2652            }
2653        }
2654        let mut events_drained: u64 = 0;
2655        while let Ok(event) = events.try_recv() {
2656            events_drained += 1;
2657            let clean_exit = matches!(
2658                event.event,
2659                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2660            );
2661            let instance_id = event.instance_id;
2662            let generation = event.generation;
2663            // Resolve and spawn before publish_control: publish_control's own
2664            // managed-record bookkeeping (reconcile_managed_record) downgrades
2665            // this exact record from Live to Dormant and clears
2666            // active_session for every Exited event, inside the same
2667            // synchronous call. Capturing (record_id, session) here — before
2668            // that runs — is the only way to observe them still Live and
2669            // still populated; the export itself must still run on its own
2670            // detached task (never awaited inline on this loop) because it
2671            // needs NativeRuntime::tick() to keep running to ever settle, and
2672            // tick() is only ever driven by this same loop. See
2673            // reconcile_context_pack_export_for_record's doc comment.
2674            if clean_exit {
2675                if let Some((record_id, session)) =
2676                    shared.managed_record_export_target_for_instance(instance_id, generation)
2677                {
2678                    let export_shared = Arc::clone(&shared);
2679                    tokio::spawn(async move {
2680                        export_shared
2681                            .reconcile_context_pack_export_for_record(&record_id, session)
2682                            .await;
2683                    });
2684                }
2685            }
2686            shared.publish_control(event);
2687        }
2688        let event_drain_elapsed = event_drain_start.elapsed();
2689
2690        let publish_terminal_frames_start = Instant::now();
2691        shared.publish_terminal_frames();
2692        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2693
2694        // Physical PTY count and the runtime's own per-phase tick timings
2695        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2696        // only `NodeShared`) cannot reach directly -- this loop is the one
2697        // place holding `&mut NativeRuntime`, so it is the only place that
2698        // can copy either out.
2699        shared
2700            .native_session_gauge
2701            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2702        *shared
2703            .runtime_tick_profile
2704            .lock()
2705            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2706
2707        let remainder_start = Instant::now();
2708        if shared.shutdown.load(Ordering::Acquire) {
2709            let started = *shutdown_started.get_or_insert_with(Instant::now);
2710            let snapshot = shared.handle.snapshot();
2711            let sessions_settled = snapshot.sessions.iter().all(|session| {
2712                !matches!(
2713                    session.status,
2714                    gate4agent_types::SessionStatus::Starting
2715                        | gate4agent_types::SessionStatus::Running
2716                        | gate4agent_types::SessionStatus::Stopping
2717                )
2718            });
2719            if runtime.active_native_sessions() == 0 && sessions_settled {
2720                return Ok(());
2721            }
2722            if started.elapsed() >= shutdown_timeout {
2723                return Err(NodeServerError::ShutdownTimedOut {
2724                    active_native_sessions: runtime.active_native_sessions(),
2725                });
2726            }
2727        }
2728        let remainder_elapsed = remainder_start.elapsed();
2729
2730        {
2731            let mut profile = shared
2732                .drive_loop_profile
2733                .lock()
2734                .unwrap_or_else(|poisoned| poisoned.into_inner());
2735            profile.record_runtime_tick(runtime_tick_elapsed);
2736            profile.record_event_drain(event_drain_elapsed);
2737            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2738            profile.record_remainder(remainder_elapsed);
2739            profile.note_iteration(Instant::now());
2740        }
2741
2742        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2743        // applied zero observations (`tick_result.observations_applied`,
2744        // which already folds in any terminal frames collected -- see
2745        // `NativeEffectDispatcher::drain_observations`) AND the
2746        // control-event subscription drained above forwarded zero events.
2747        // Command ingress and dispatched effects are deliberately NOT part
2748        // of this definition: an idle node still runs `step_control_plane`
2749        // and every provider supervisor's `tick` regardless, every 10ms --
2750        // so "nothing" here means nothing OBSERVABLE left this iteration,
2751        // matching what a reader would assume from `drive_loop_iterations`'s
2752        // own field name, not that the tick itself was free of work.
2753        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2754        if tick_result.observations_applied == 0 && events_drained == 0 {
2755            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2756        }
2757
2758        // A sweep cadence, not a policy value -- see
2759        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2760        // policy itself (whether this does anything at all) lives entirely
2761        // in `NodeShared::session_record_retention`.
2762        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2763            shared.retire_unavailable_session_records().await;
2764            last_retention_sweep = Instant::now();
2765        }
2766        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2767        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2768            for address in shared.take_due_provisional_spawns() {
2769                shared.reap_provisional_spawn(address).await;
2770            }
2771            last_provisional_reap = Instant::now();
2772        }
2773
2774        // Fast-poll for 300ms after PTY input so its echo is read and published
2775        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2776        let hot = shared
2777            .pty_input_marks
2778            .lock()
2779            .unwrap_or_else(|poisoned| poisoned.into_inner())
2780            .values()
2781            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2782        if hot {
2783            sleep(Duration::from_millis(1)).await;
2784        } else {
2785            tokio::select! {
2786                _ = sleep(Duration::from_millis(10)) => {}
2787                _ = shared.pty_input_wake.notified() => {}
2788            }
2789        }
2790    }
2791}
2792
2793#[derive(Clone)]
2794pub struct NodeShutdownHandle {
2795    shared: Arc<NodeShared>,
2796}
2797
2798impl NodeShutdownHandle {
2799    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2800        self.shared
2801            .begin_shutdown()
2802            .await
2803            .map_err(NodeServerError::ShutdownDispatch)
2804    }
2805}
2806
2807async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2808    #[cfg(windows)]
2809    {
2810    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2811    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2812    return tokio::select! {
2813        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2814        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2815    };
2816    }
2817    #[cfg(unix)]
2818    {
2819        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2820        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2821        tokio::select! {
2822            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2823            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2824        }
2825    }
2826}
2827
2828/// The providers this node actually runs.
2829///
2830/// `builtin_registry()` is a reference catalog of everything the project has
2831/// ever described; the fleet is this list, and it is deliberately short. A
2832/// provider only belongs here if it can be exercised for real -- there is a
2833/// subscription behind it and a failure in it can be reproduced and
2834/// debugged. Carrying one that cannot is carrying code nobody can verify,
2835/// which is worse than not supporting it: it looks supported.
2836///
2837/// A provider dropped from the fleet by this rule is removed outright, not
2838/// carried dark: no catalog entry, no adapter, no dead code claiming support
2839/// for something nobody can verify runs.
2840/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2841fn shell_host_spec() -> AgentSpec {
2842    AgentSpec {
2843        id: AgentId::new("shell").expect("shell agent id"),
2844        revision: "hatchery:shell-pty-1".to_owned(),
2845        display_name: "Host shell".to_owned(),
2846        detection: DetectionSpec {
2847            command: "bash".to_owned(),
2848            aliases: vec!["sh".to_owned(), "shell".to_owned()],
2849            required_commands: Vec::new(),
2850            unsupported_platforms: Vec::new(),
2851        },
2852        launch: LaunchSpec {
2853            // Absolute path so admission does not depend on PATH.
2854            program: "/bin/bash".to_owned(),
2855            fixed_args: vec!["-i".to_owned()],
2856        },
2857        expected_processes: vec![ProcessMatcher::Exact {
2858            name: "bash".to_owned(),
2859        }],
2860        prompt: PromptSpec {
2861            initial: InitialPromptMode::None,
2862            native_draft: None,
2863        },
2864        readiness: AgentReadinessSpec::default(),
2865        capabilities: AgentCapabilities {
2866            agent_commands: None,
2867            transports: AgentTransportCapabilities {
2868                pty: true,
2869                pty_adapter: None,
2870                pipe: None,
2871                acp: None,
2872            },
2873            adapters: AgentAdapterCapabilities::default(),
2874        },
2875        verification: SpecVerification::Gate4AgentVerified,
2876    }
2877}
2878
2879fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2880    let mut specs = vec![shell_host_spec()];
2881    specs.extend(
2882        builtin_registry()
2883            .iter()
2884            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2885            .cloned(),
2886    );
2887    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2888}
2889
2890fn provider_contract_manifest(
2891    catalog: &AgentRegistry,
2892) -> Result<
2893    (
2894        Vec<ProviderContractSupport>,
2895        Vec<ProviderAdapterContractSupport>,
2896    ),
2897    NodeServerError,
2898> {
2899    let mut provider_contracts = Vec::new();
2900    let mut provider_adapter_contracts = Vec::new();
2901    for spec in catalog.iter() {
2902        let provider = spec.id.clone();
2903        let revision = ProviderContractRevision::new(spec.revision.clone())
2904            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2905        provider_contracts.push(ProviderContractSupport {
2906            provider: provider.clone(),
2907            revision,
2908        });
2909        for (family, binding) in declared_adapter_bindings(spec)? {
2910            let revision = AdapterContractRevision::new(binding.revision.clone())
2911                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2912            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2913                provider: provider.clone(),
2914                family,
2915                adapter_id: binding.id.clone(),
2916                revision,
2917            });
2918        }
2919    }
2920    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2921        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2922    Ok((provider_contracts, provider_adapter_contracts))
2923}
2924
2925fn declared_adapter_bindings(
2926    spec: &AgentSpec,
2927) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2928    let mut bindings = Vec::new();
2929    push_declared_binding(
2930        &mut bindings,
2931        AdapterFamily::PtySemantic,
2932        spec.capabilities.transports.pty_adapter.as_ref(),
2933    )?;
2934    push_declared_binding(
2935        &mut bindings,
2936        AdapterFamily::Pipe,
2937        spec.capabilities.adapters.pty_sidecar.as_ref(),
2938    )?;
2939    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2940    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2941        gate4agent_types::PipeProtocol::SemanticNdjson
2942        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2943        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2944    });
2945    push_declared_binding(
2946        &mut bindings,
2947        AdapterFamily::Pipe,
2948        pipe_transport
2949            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2950            .map(|transport| &transport.adapter),
2951    )?;
2952    push_declared_binding(
2953        &mut bindings,
2954        AdapterFamily::Acp,
2955        spec.capabilities
2956            .transports
2957            .acp
2958            .as_ref()
2959            .map(|transport| &transport.adapter),
2960    )?;
2961    push_declared_binding(
2962        &mut bindings,
2963        AdapterFamily::Hook,
2964        spec.capabilities.adapters.hook.as_ref(),
2965    )?;
2966    push_declared_binding(
2967        &mut bindings,
2968        AdapterFamily::ManagedHook,
2969        spec.capabilities.adapters.managed_hook.as_ref(),
2970    )?;
2971    push_declared_binding(
2972        &mut bindings,
2973        AdapterFamily::OneShot,
2974        pipe_transport
2975            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2976            .map(|transport| &transport.adapter),
2977    )?;
2978    push_declared_binding(
2979        &mut bindings,
2980        AdapterFamily::OneShot,
2981        spec.capabilities.adapters.one_shot.as_ref(),
2982    )?;
2983    push_declared_binding(
2984        &mut bindings,
2985        AdapterFamily::History,
2986        spec.capabilities.adapters.history.as_ref(),
2987    )?;
2988    push_declared_binding(
2989        &mut bindings,
2990        AdapterFamily::Resume,
2991        spec.capabilities.adapters.resume.as_ref(),
2992    )?;
2993    push_declared_binding(
2994        &mut bindings,
2995        AdapterFamily::SessionOptions,
2996        spec.capabilities.adapters.session_options.as_ref(),
2997    )?;
2998    push_declared_binding(
2999        &mut bindings,
3000        AdapterFamily::CapabilityProbe,
3001        spec.capabilities.adapters.capability_probe.as_ref(),
3002    )?;
3003    Ok(bindings)
3004}
3005
3006fn push_declared_binding<'a>(
3007    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3008    family: AdapterFamily,
3009    binding: Option<&'a AdapterBinding>,
3010) -> Result<(), NodeServerError> {
3011    let Some(binding) = binding else {
3012        return Ok(());
3013    };
3014    if let Some((_, existing)) = bindings
3015        .iter()
3016        .find(|(existing_family, _)| *existing_family == family)
3017    {
3018        if *existing == binding {
3019            return Ok(());
3020        }
3021        return Err(NodeServerError::ProviderContractManifest(format!(
3022            "provider declares conflicting {family:?} adapter bindings"
3023        )));
3024    }
3025    bindings.push((family, binding));
3026    Ok(())
3027}
3028
3029fn merge_durable_state(
3030    configured: &[WorkspaceConfig],
3031    mut loaded: LoadedNodeState,
3032) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3033    let mut workspaces = configured
3034        .iter()
3035        .cloned()
3036        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3037        .collect::<BTreeMap<_, _>>();
3038    let mut roots = workspaces
3039        .values()
3040        .map(|workspace| {
3041            (
3042                platform::root_identity(&workspace.canonical_root),
3043                workspace.workspace_id.clone(),
3044            )
3045        })
3046        .collect::<BTreeMap<_, _>>();
3047    let mut warning = loaded
3048        .warning
3049        .take()
3050        .map(|message| session_registry::sanitized_persistence_summary(&message));
3051
3052    for (workspace_id, canonical_root) in loaded.workspaces {
3053        if let Some(existing) = workspaces.get(&workspace_id) {
3054            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3055                return Err(NodeServerError::DurableState(io::Error::new(
3056                    io::ErrorKind::InvalidData,
3057                    DURABLE_STATE_CONFLICT_ERROR,
3058                )));
3059            }
3060            continue;
3061        }
3062        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3063            return Err(NodeServerError::DurableState(io::Error::new(
3064                io::ErrorKind::InvalidData,
3065                DURABLE_STATE_CONFLICT_ERROR,
3066            )));
3067        }
3068        if !Path::new(&canonical_root).is_dir() {
3069            warning.get_or_insert_with(|| {
3070                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3071            });
3072            continue;
3073        }
3074        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3075            NodeServerError::DurableState(io::Error::new(
3076                io::ErrorKind::InvalidData,
3077                DURABLE_STATE_CONFLICT_ERROR,
3078            ))
3079        })?;
3080        roots.insert(
3081            platform::root_identity(&workspace.canonical_root),
3082            workspace.workspace_id.clone(),
3083        );
3084        workspaces.insert(workspace_id, workspace);
3085    }
3086
3087    for record in &mut loaded.records {
3088        match workspaces.get(&record.workspace_id) {
3089            Some(workspace)
3090                if platform::roots_equal(
3091                    &workspace.canonical_root,
3092                    windows_path_text(&record.canonical_root),
3093                ) => {}
3094            Some(_) => {
3095                return Err(NodeServerError::DurableState(io::Error::new(
3096                    io::ErrorKind::InvalidData,
3097                    DURABLE_STATE_CONFLICT_ERROR,
3098                )));
3099            }
3100            None => {
3101                record.state = ManagedSessionState::Unavailable;
3102                record.active_session = None;
3103                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3104            }
3105        }
3106    }
3107    Ok((workspaces.into_values().collect(), loaded.records, warning))
3108}
3109
3110/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3111/// own doc for the mechanism this exists to close.
3112struct RejectedCommandReason {
3113    message: String,
3114    /// True when the kernel refused this command specifically because the
3115    /// agent does not declare the requested transport (`KernelCommandError::
3116    /// UnsupportedTransport`) -- lets the reader choose the named
3117    /// `NodeFailureCode::UnsupportedTransport` over the generic
3118    /// `BackendOperationFailed` bucket, so this one well-known rejection
3119    /// keeps its identity all the way to the operator wire.
3120    unsupported_transport: bool,
3121    expires_at: Instant,
3122}
3123
3124struct NodeShared {
3125    handle: Gate4AgentHandle,
3126    access_token: String,
3127    node_id: NodeId,
3128    incarnation_id: NodeIncarnationId,
3129    started_at_unix_ms: u64,
3130    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3131    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3132    managed_worktree_profiles:
3133        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3134    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3135    enabled_providers: Vec<AgentId>,
3136    provider_runtime_statuses: ProviderRuntimeStatuses,
3137    provider_runtime_status_updates:
3138        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3139    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3140    provider_contracts: Vec<ProviderContractSupport>,
3141    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3142    spawn_profiles: SpawnProfileRegistry,
3143    environment_profiles:
3144        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3145    environment_materialization_profiles:
3146        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3147    /// Station **network** allowlist catalog (opaque policy ids only).
3148    /// Empty by default — any `network_allowlist` on spawn must be registered
3149    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3150    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3151    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3152    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3153    /// Never holds cookies / OAuth / proxy credentials.
3154    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3155    /// Node-local exclusive dig2browser station profile leases (feature
3156    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3157    /// cleared on session end. Never ImportSession / cookies on C2.
3158    #[cfg(feature = "dig2-station-probe")]
3159    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3160    bundle_catalog: RwLock<BundleCatalog>,
3161    delivery_store: Mutex<Option<DeliveryStore>>,
3162    context_pack_store: Mutex<Option<ContextPackStore>>,
3163    context_catalog: RwLock<ContextPackCatalog>,
3164    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3165    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3166    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3167    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3168    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3169    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3170    /// nick -> the session that holds that m4a account (for letter wakes).
3171    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3172    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3173    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3174    managed_spawn_replays:
3175        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3176    controller: Mutex<Option<ControllerLease>>,
3177    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3178    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3179    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3180    history: Mutex<NodeEventHistory>,
3181    event_tx: broadcast::Sender<NodeEventEnvelope>,
3182    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3183    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3184    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3185    /// exactly rather than riding the durable `event_tx` history (see
3186    /// `publish`'s own assertion): a subscriber who never negotiated
3187    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3188    /// these, the same guarantee `include_terminal_frame_events` already
3189    /// gives terminal frames.
3190    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3191    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3192    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3193    /// broadcast rather than a ride on the durable `event_tx` history. A
3194    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3195    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3196    /// finite capacity and its willingness to silently evict a lagging
3197    /// subscriber's backlog is exactly how the call vanished with neither
3198    /// side ever logging it (see `publish_transient`'s own doc).
3199    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3200    terminal_frame_watermarks:
3201        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3202    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3203    pty_input_wake: tokio::sync::Notify,
3204    /// Per session: when the last PTY input was dispatched (latency probe +
3205    /// a short fast-poll window so the echo is published within ~1ms).
3206    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3207    next_connection_id: AtomicU64,
3208    next_instance_id: AtomicU64,
3209    next_command_id: AtomicU64,
3210    shutdown: AtomicBool,
3211    shutdown_notify: Notify,
3212    preauth_slots: Arc<Semaphore>,
3213    authenticated_slots: Arc<Semaphore>,
3214    inspection_slots: Arc<Semaphore>,
3215    mutation_gate: AsyncMutex<()>,
3216    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3217    session_records: Mutex<SessionRecords>,
3218    state_path: Option<PathBuf>,
3219    persistence_error: RwLock<Option<String>>,
3220    state_transaction: Mutex<()>,
3221    input_settle_timeout_ms: u64,
3222    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3223    /// comment. Written once per iteration by
3224    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3225    drive_loop_profile: Mutex<DriveLoopProfiler>,
3226    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3227    /// so an overwrite each iteration is cheap. The runtime itself is not
3228    /// reachable from here (it is owned locally by
3229    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3230    /// tick timings reach `GET /metrics`.
3231    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3232    /// The live shell-efficiency profile, shared with the per-instance
3233    /// worker loops that fold into it.
3234    ///
3235    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3236    /// profile` above keeps: producing a snapshot sorts every ring behind
3237    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3238    /// field set at construction because `NodeShared` is built before the
3239    /// `NativeRuntime` that owns the profile exists -- the drive loop
3240    /// publishes it once, before its first iteration. Unset means the
3241    /// runtime has not started driving yet, which `GET /metrics` reports as
3242    /// empty series rather than as zeroes it never measured.
3243    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3244    /// Physical PTY sessions the native runtime currently owns
3245    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3246    /// loop iteration -- deliberately a separate count from
3247    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3248    /// session bookkeeping), since the two can disagree and the gap is
3249    /// itself a diagnostic signal `GET /metrics` exposes.
3250    native_session_gauge: AtomicUsize,
3251    /// Lifetime totals for the per-connection serve loop, summed across
3252    /// every live connection.
3253    ///
3254    /// The drive loop's own phase timings account for about ten
3255    /// microseconds an iteration on an idle node, yet the process burns a
3256    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3257    /// with none, 23% with two, measured). That gap is somewhere in
3258    /// serving a connection, and there was no way to see it: iterations
3259    /// says whether the loop is spinning rather than sleeping, and events
3260    /// says whether it is spinning over real work or over nothing.
3261    connection_loop_iterations: AtomicU64,
3262    connection_events_sent: AtomicU64,
3263    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3264    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3265    /// `NativeRuntime::tick` observations AND forwarded zero control
3266    /// events; see the exact "produced nothing" definition at the site in
3267    /// `drive_runtime_until_shutdown` that increments these. Read twice
3268    /// and divide by the interval, same rule as `connections` above --
3269    /// `idle / total` is backlog item 5's "wake on work" number.
3270    /// Lifetime count of control commands the kernel refused.
3271    rejected_commands_total: AtomicU64,
3272    /// The named reason behind a recent kernel rejection, by the
3273    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3274    /// command -- populated by `drive_runtime_until_shutdown` for every
3275    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3276    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3277    /// `CommandId` among them.
3278    ///
3279    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3280    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3281    /// problem -- full or disconnected -- never for the kernel refusing the
3282    /// command's content (an unsupported transport, an unknown agent, ...);
3283    /// that verdict exists only once `NativeRuntime::tick` has processed the
3284    /// command, one drive-loop iteration later, in a different task than
3285    /// whichever caller is awaiting the spawn. Without this map, a spawn
3286    /// wait loop that polls `self.handle.snapshot()` for a session that
3287    /// will now never appear has no way to learn why -- it can only spin
3288    /// until its own commit deadline and report a generic "did not commit"
3289    /// timeout, discarding a reason the kernel already named precisely (see
3290    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3291    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3292    drive_loop_iterations_total: AtomicU64,
3293    drive_loop_iterations_idle: AtomicU64,
3294    /// The operator-chosen age/keep-N policy the retention sweep applies to
3295    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3296    /// see `SessionRecordRetentionConfig`'s own doc comment.
3297    session_record_retention: SessionRecordRetentionConfig,
3298    /// Lifetime count of managed session records the retention sweep has
3299    /// retired. Same tier as `rejected_commands_total` above: a live,
3300    /// process-lifetime instrument, never persisted and never reloaded from
3301    /// `state-v1.json` -- a restart legitimately resets it, matching every
3302    /// other `*_total` counter here.
3303    retired_records_total: AtomicU64,
3304    #[cfg(feature = "fixture")]
3305    fixture_semantic_hook_policy: bool,
3306    #[cfg(feature = "fixture")]
3307    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3308}
3309
3310impl Drop for NodeShared {
3311    fn drop(&mut self) {
3312        SessionLifecycleHook::on_shutdown(self);
3313    }
3314}
3315
3316#[derive(Clone, Debug, Eq, PartialEq)]
3317struct SessionBinding {
3318    workspace_id: WorkspaceId,
3319    generation: SessionGeneration,
3320    runtime_policy: ProviderRuntimePolicy,
3321    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3322    record_id: Option<SessionRecordId>,
3323    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3324    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3325    bundle: Option<ResolvedBundleReceipt>,
3326    context: Option<ResolvedContextPackReceipt>,
3327    materialization_id: Option<MaterializationId>,
3328}
3329
3330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3331enum SpawnRecordPolicy {
3332    ProviderIdentityOnly,
3333    Always,
3334}
3335
3336struct NativeEnvironmentSelectionGuard {
3337    control: Option<NativeLaunchProfileControl>,
3338    instance_id: AgentInstanceId,
3339}
3340
3341struct NativeInstanceOverlayGuard {
3342    control: Option<NativeLaunchProfileControl>,
3343    instance_id: AgentInstanceId,
3344}
3345
3346struct NativeHarnessMcpOverlayGuard {
3347    control: NativeLaunchProfileControl,
3348    instance_id: AgentInstanceId,
3349}
3350
3351impl NativeHarnessMcpOverlayGuard {
3352    fn retain(self) { std::mem::forget(self); }
3353}
3354
3355impl Drop for NativeHarnessMcpOverlayGuard {
3356    fn drop(&mut self) {
3357        self.control
3358            .clear_native_mcp_server_launch_overlay(self.instance_id);
3359    }
3360}
3361
3362impl NativeInstanceOverlayGuard {
3363    fn retain(mut self) {
3364        self.control = None;
3365    }
3366}
3367
3368impl Drop for NativeInstanceOverlayGuard {
3369    fn drop(&mut self) {
3370        if let Some(control) = self.control.as_ref() {
3371            control.clear_native_instance_launch_overlay(self.instance_id);
3372        }
3373    }
3374}
3375
3376enum PreparedNativeLaunchOverlay {
3377    Environment(NativeLaunchEnvironmentOverlay),
3378    Instance(NativeInstanceLaunchOverlay),
3379}
3380
3381impl NativeEnvironmentSelectionGuard {
3382    fn retain(mut self) {
3383        self.control = None;
3384    }
3385}
3386
3387impl Drop for NativeEnvironmentSelectionGuard {
3388    fn drop(&mut self) {
3389        if let Some(control) = self.control.as_ref() {
3390            control.clear_native_launch_profile_selection(self.instance_id);
3391        }
3392    }
3393}
3394
3395struct SessionMaterializationGuard<'a> {
3396    shared: &'a NodeShared,
3397    id: Option<MaterializationId>,
3398    /// Absolute ProviderHome (`home/`) under the session materialization root.
3399    /// Used for Claude station network `--settings` sidecar writes.
3400    provider_home: Option<PathBuf>,
3401}
3402
3403impl SessionMaterializationGuard<'_> {
3404    fn id(&self) -> Option<&MaterializationId> {
3405        self.id.as_ref()
3406    }
3407
3408    fn provider_home(&self) -> Option<&Path> {
3409        self.provider_home.as_deref()
3410    }
3411
3412    fn retain(mut self) {
3413        self.id = None;
3414    }
3415}
3416
3417impl Drop for SessionMaterializationGuard<'_> {
3418    fn drop(&mut self) {
3419        if let Some(id) = self.id.take() {
3420            let _ = self.shared.cleanup_materialization(&id);
3421        }
3422    }
3423}
3424
3425
3426/// Port: product-side reactions to session lifecycle transitions
3427/// (station lease, harness MCP overlay/registry, environment launch overlays).
3428trait SessionLifecycleHook {
3429    /// A binding was removed (unbind or spawn rollback).
3430    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3431    /// A session reached a terminal control event that revokes its harness MCP door.
3432    fn on_session_ended(&self, address: &SessionAddress);
3433    /// Node shutdown (or drop): revoke every harness MCP reservation.
3434    fn on_shutdown(&self);
3435    /// A harness MCP reservation was aborted before activation.
3436    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3437    /// Release session-owned materialization for a removed binding.
3438    fn cleanup_session_materialization(
3439        &self,
3440        address: &SessionAddress,
3441        binding: &SessionBinding,
3442    ) -> Result<(), NodeFailure>;
3443}
3444
3445impl SessionLifecycleHook for NodeShared {
3446    fn on_session_ended(&self, address: &SessionAddress) {
3447        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3448            registry.revoke_session(address);
3449        }
3450        if let Some(control) = self.native_launch_profile_control.as_ref() {
3451            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3452        }
3453    }
3454
3455    fn on_shutdown(&self) {
3456        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3457            let instances = registry.shutdown();
3458            if let Some(control) = self.native_launch_profile_control.as_ref() {
3459                for instance_id in instances {
3460                    control.clear_native_mcp_server_launch_overlay(instance_id);
3461                }
3462            }
3463        }
3464    }
3465
3466    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3467        if let Some(control) = self.native_launch_profile_control.as_ref() {
3468            control.clear_native_mcp_server_launch_overlay(instance_id);
3469        }
3470    }
3471
3472    fn cleanup_session_materialization(
3473        &self,
3474        address: &SessionAddress,
3475        binding: &SessionBinding,
3476    ) -> Result<(), NodeFailure> {
3477        self.cleanup_session_owned_materialization(address, binding)
3478    }
3479
3480    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3481        // Exclusive dig2browser station profile lease is node-local; clear
3482        // on every session unbind / rollback. Never ImportSession/cookies.
3483        #[cfg(feature = "dig2-station-probe")]
3484        {
3485            crate::dig2_station_lease::release_lease_for_holder(
3486                &self.browser_station_leases,
3487                address.session.instance_id,
3488            );
3489        }
3490        if let Some(control) = self.native_launch_profile_control.as_ref() {
3491            control.clear_native_mcp_server_launch_overlay(
3492                address.session.instance_id,
3493            );
3494        }
3495        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3496            registry.revoke_session(address);
3497        }
3498        if removed
3499            .is_some_and(|binding| {
3500                binding.environment_profile.is_some()
3501                    || binding.bundle.is_some()
3502                    || binding.context.is_some()
3503            })
3504        {
3505            if let Some(control) = self.native_launch_profile_control.as_ref() {
3506                control.clear_native_launch_profile_selection(
3507                    address.session.instance_id,
3508                );
3509                control.clear_native_instance_launch_overlay(
3510                    address.session.instance_id,
3511                );
3512            }
3513        }
3514    }
3515}
3516
3517struct SpawnPrepareRequest<'r> {
3518    instance_id: AgentInstanceId,
3519    address: &'r SessionAddress,
3520    provider: &'r AgentId,
3521    mode: SessionMode,
3522    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3523    bundle: Option<&'r ResolvedBundleReceipt>,
3524    context: Option<&'r ResolvedContextPackReceipt>,
3525    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3526    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3527    approval_level: ApprovalLevel,
3528    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3529    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3530}
3531
3532/// Guards held between "session identity minted" and "launch committed".
3533/// Field order is the drop order and matches the pre-refactor local order
3534/// (instance overlay, environment selection, materialization, harness, station lease).
3535struct PreparedSpawn<'a> {
3536    instance_overlay: Option<NativeInstanceOverlayGuard>,
3537    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3538    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3539    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3540    #[cfg(feature = "dig2-station-probe")]
3541    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3542    materialization_id: Option<MaterializationId>,
3543}
3544
3545impl PreparedSpawn<'_> {
3546    /// Session-owned launch state now belongs to the binding.
3547    fn retain_session_guards(&mut self) {
3548        if let Some(selection) = self.environment_selection.take() {
3549            selection.retain();
3550        }
3551        if let Some(overlay) = self.instance_overlay.take() {
3552            overlay.retain();
3553        }
3554        if let Some(guard) = self.materialization_guard.take() {
3555            guard.retain();
3556        }
3557    }
3558
3559    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3560    fn retain_launch_guards(&mut self) {
3561        if let Some(overlay) = self.harness_mcp_overlay.take() {
3562            overlay.retain();
3563        }
3564        #[cfg(feature = "dig2-station-probe")]
3565        if let Some(guard) = self.browser_station_lease_guard.take() {
3566            guard.retain();
3567        }
3568    }
3569}
3570
3571/// Port: everything product-side that must be prepared before a session is
3572/// bound and started (station lease, harness MCP overlay, materialization,
3573/// environment selection, network overlays, launch overlay).
3574trait SpawnPreparer {
3575    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3576    /// Select the native launch profile for an environment-profile session.
3577    fn select_environment(
3578        &self,
3579        instance_id: AgentInstanceId,
3580        provider: &AgentId,
3581        mode: SessionMode,
3582        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3583    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3584    /// Install a prepared launch overlay for the instance.
3585    fn install_launch_overlay(
3586        &self,
3587        instance_id: AgentInstanceId,
3588        overlay: PreparedNativeLaunchOverlay,
3589    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3590}
3591
3592impl SpawnPreparer for NodeShared {
3593    fn select_environment(
3594        &self,
3595        instance_id: AgentInstanceId,
3596        provider: &AgentId,
3597        mode: SessionMode,
3598        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3599    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3600        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3601    }
3602
3603    fn install_launch_overlay(
3604        &self,
3605        instance_id: AgentInstanceId,
3606        overlay: PreparedNativeLaunchOverlay,
3607    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3608        self.install_prepared_launch_overlay(instance_id, overlay)
3609    }
3610
3611    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3612        let SpawnPrepareRequest {
3613            instance_id,
3614            address,
3615            provider,
3616            mode,
3617            environment_profile,
3618            bundle,
3619            context,
3620            managed_authority,
3621            harness_mcp,
3622            approval_level,
3623            network_allowlist,
3624            browser_profile_id,
3625        } = request;
3626        // Track A follow-on: exclusive node-local BrowserStationLease after
3627        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3628        // never ImportSession / cookies on C2. Linux/feature-off: probe
3629        // already refused or stub; this block is cfg'd out when feature off.
3630        #[cfg(feature = "dig2-station-probe")]
3631        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3632            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3633            match acquire_lease(
3634                &self.browser_station_leases,
3635                profile_id,
3636                instance_id,
3637                unix_time_ms(),
3638            ) {
3639                Ok(guard) => Some(guard),
3640                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3641                    return Err(failure(
3642                        NodeFailureCode::BrowserStationProfileBusy,
3643                        "dig2browser station profile is already leased by another session",
3644                    ));
3645                }
3646                Err(BrowserStationLeaseError::CapacityExceeded) => {
3647                    return Err(failure(
3648                        NodeFailureCode::BrowserStationProfileBusy,
3649                        "dig2browser station profile lease capacity exhausted",
3650                    ));
3651                }
3652                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3653                    return Err(failure(
3654                        NodeFailureCode::BrowserStationProfileBusy,
3655                        "session already holds a dig2browser station profile lease",
3656                    ));
3657                }
3658            }
3659        } else {
3660            None
3661        };
3662        #[cfg(not(feature = "dig2-station-probe"))]
3663        let _ = browser_profile_id;
3664        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3665            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3666            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3667            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3668            // both read the identical overlay resolved below, one as child
3669            // environment, the other translated into one stdio MCP server
3670            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3671            // only, and nothing downstream reads a harness-MCP overlay for
3672            // that transport yet.
3673            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3674            {
3675                return Err(failure(
3676                    NodeFailureCode::BindingMismatch,
3677                    "harness MCP reservation does not match the exact PTY/ACP provider",
3678                ));
3679            }
3680            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3681            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3682                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3683            })?;
3684            // Every name below is the caller's, carried in the reservation's
3685            // launch description: which environment variables receive the
3686            // endpoint, token and helper program, what the MCP server is called,
3687            // which argv it gets and which stale variables are erased.
3688            let launch = prepared.launch();
3689            let mut env_entries = vec![
3690                (
3691                    OsString::from(launch.endpoint_env.as_str()),
3692                    prepared.endpoint().as_os_str().to_os_string(),
3693                ),
3694                (
3695                    OsString::from(launch.token_env.as_str()),
3696                    prepared.token().expose().into(),
3697                ),
3698                (
3699                    OsString::from(launch.program_env.as_str()),
3700                    prepared.helper_program().as_os_str().to_os_string(),
3701                ),
3702            ];
3703            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3704                env_entries.push(trace_entry);
3705            }
3706            if let Some(door) = crate::door::door_listen_addr() {
3707                env_entries.push((
3708                    OsString::from("HATCHERY_NODE_DOOR"),
3709                    OsString::from(door.to_string()),
3710                ));
3711                // Same key shape as HQ browser/draft ids:
3712                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3713                // slot across node boots when the instance counter resets.
3714                let session_key = format!(
3715                    "pty-{}-{}-{}",
3716                    self.incarnation_id,
3717                    address.workspace_id.as_str(),
3718                    address.session.instance_id.0
3719                );
3720                env_entries.push((
3721                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3722                    OsString::from(session_key),
3723                ));
3724            }
3725            let overlay = NativeMcpServerLaunchOverlay::new(
3726                provider.clone(),
3727                launch.server_name.as_str(),
3728                prepared.helper_program().as_os_str().to_os_string(),
3729                launch.args.iter().map(OsString::from).collect(),
3730                env_entries,
3731                launch.scrub_env.iter().map(OsString::from).collect(),
3732            ).map_err(|_| failure(
3733                NodeFailureCode::HarnessMcpUnavailable,
3734                "harness MCP launch overlay is unavailable",
3735            ))?;
3736            control
3737                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3738                .map_err(|_| failure(
3739                    NodeFailureCode::HarnessMcpUnavailable,
3740                    "harness MCP launch overlay could not be installed",
3741                ))?;
3742            // State-change line: the overlay is installed and will be read
3743            // back by the PTY child's environment or, for ACP,
3744            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3745            // time -- no secrets here, only which instance/provider/mode
3746            // now carries a harness-MCP door.
3747            tracing::info!(
3748                node_id = %self.node_id,
3749                instance_id = ?instance_id,
3750                provider = %provider,
3751                mode = ?mode,
3752                "harness MCP overlay installed for spawn",
3753            );
3754            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3755        } else {
3756            None
3757        };
3758        let prepared_materialization = self.prepare_session_materialization(
3759            &address,
3760            &provider,
3761            mode,
3762            environment_profile,
3763            bundle,
3764            context,
3765            managed_authority.cloned(),
3766        )?;
3767        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3768            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3769            None => (None, None),
3770        };
3771        let materialization_id = materialization_guard
3772            .as_ref()
3773            .and_then(SessionMaterializationGuard::id)
3774            .cloned();
3775        let mut environment_selection = SpawnPreparer::select_environment(self, 
3776            instance_id,
3777            &provider,
3778            mode,
3779            environment_profile,
3780        )?;
3781        // Station network overlays: Codex `-c network_access` and/or Claude
3782        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3783        let provider_home = materialization_guard
3784            .as_ref()
3785            .and_then(SessionMaterializationGuard::provider_home);
3786        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3787            &provider,
3788            mode,
3789            approval_level,
3790            network_allowlist,
3791            provider_home,
3792        )?;
3793        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3794            (prepared, args) if args.is_empty() => prepared,
3795            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3796                overlay.append_extra_args(args).map_err(|_| {
3797                    failure(
3798                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3799                        "network allowlist overlay could not merge with instance launch args",
3800                    )
3801                })?;
3802                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3803            }
3804            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3805                Some(PreparedNativeLaunchOverlay::Instance(
3806                    overlay
3807                        .into_instance_with_extra_args(args)
3808                        .map_err(|_| {
3809                            failure(
3810                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3811                                "network allowlist overlay could not merge with environment overlay",
3812                            )
3813                        })?,
3814                ))
3815            }
3816            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3817                NativeInstanceLaunchOverlay::new(
3818                    provider.clone(),
3819                    match mode {
3820                        SessionMode::Pty => TransportKind::Pty,
3821                        SessionMode::Acp => TransportKind::Acp,
3822                        SessionMode::Inline => TransportKind::Pipe,
3823                    },
3824                    Vec::new(),
3825                    args,
3826                )
3827                .map_err(|_| {
3828                    failure(
3829                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3830                        "network allowlist overlay is invalid for this transport",
3831                    )
3832                })?,
3833            )),
3834        };
3835        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3836        // overlay or appended to the overlay the spawn already carries.
3837        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3838            (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3839                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3840                match accounts.provision(m4a_provider, &session_key) {
3841                    Ok(account) => {
3842                        let identity = account.env_mutations();
3843                        let merged = match environment_overlay {
3844                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3845                                .map(PreparedNativeLaunchOverlay::Instance),
3846                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3847                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3848                            }
3849                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3850                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3851                            }
3852                        }
3853                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3854                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3855                        self.m4a_live
3856                            .lock()
3857                            .unwrap_or_else(|p| p.into_inner())
3858                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3859                        Some(merged)
3860                    }
3861                    Err(error) => {
3862                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3863                        environment_overlay
3864                    }
3865                }
3866            }
3867            (SessionMode::Pty, Some(_), None) => {
3868                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3869                environment_overlay
3870            }
3871            _ => environment_overlay,
3872        };
3873        let mut instance_overlay = environment_overlay
3874            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3875            .transpose()?
3876            .flatten();
3877        Ok(PreparedSpawn {
3878            instance_overlay,
3879            environment_selection,
3880            materialization_guard,
3881            harness_mcp_overlay,
3882            #[cfg(feature = "dig2-station-probe")]
3883            browser_station_lease_guard,
3884            materialization_id,
3885        })
3886    }
3887}
3888
3889struct SessionRecords {
3890    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3891}
3892
3893struct SpawnIdempotencyCache {
3894    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3895}
3896
3897struct SpawnIdempotencyEntry {
3898    value: SpawnIdempotencyValue,
3899    expires_at: Instant,
3900}
3901
3902enum SpawnIdempotencyValue {
3903    Standard {
3904        spec: SpawnSpec,
3905        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3906    },
3907    Managed {
3908        request: ManagedWorktreeSpawnRequest,
3909        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3910    },
3911    HarnessMcp,
3912}
3913
3914enum DurableManagedSpawnReplayDecision {
3915    Reserved,
3916    PendingLinked,
3917    Committed(ManagedWorktreeSpawnReceipt),
3918}
3919
3920impl SpawnIdempotencyValue {
3921    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3922        match self {
3923            Self::Standard { result, .. } => result
3924                .as_ref()
3925                .ok()
3926                .and_then(|receipt| receipt.context.as_ref()),
3927            Self::Managed { result, .. } => result
3928                .as_ref()
3929                .ok()
3930                .and_then(|receipt| receipt.spawn.context.as_ref()),
3931            Self::HarnessMcp => None,
3932        }
3933        .is_some_and(|receipt| &receipt.id == context_id)
3934    }
3935}
3936
3937#[derive(Clone, Copy)]
3938struct ControllerLease {
3939    connection_id: u64,
3940    expires_at: Instant,
3941}
3942
3943struct NodeEventHistory {
3944    last_sequence: u64,
3945    replay_floor_sequence: u64,
3946    events: VecDeque<NodeEventEnvelope>,
3947    record_providers: BTreeMap<SessionRecordId, AgentId>,
3948    removed_record_providers: BTreeMap<u64, AgentId>,
3949}
3950
3951impl NodeEventHistory {
3952    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3953        Self {
3954            last_sequence: 0,
3955            replay_floor_sequence: 1,
3956            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3957            record_providers,
3958            removed_record_providers: BTreeMap::new(),
3959        }
3960    }
3961}
3962
3963impl NodeShared {
3964    fn new_with_incarnation(
3965        handle: Gate4AgentHandle,
3966        access_token: String,
3967        node_id: NodeId,
3968        incarnation_id: NodeIncarnationId,
3969        workspaces: Vec<WorkspaceConfig>,
3970        enabled_providers: Vec<AgentId>,
3971        provider_runtime_statuses: ProviderRuntimeStatuses,
3972        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3973        provider_contracts: Vec<ProviderContractSupport>,
3974        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3975        spawn_profiles: SpawnProfileRegistry,
3976        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3977        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3978        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3979        state_path: Option<PathBuf>,
3980        records: Vec<ManagedSessionRecord>,
3981        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3982        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3983        materializations: Vec<MaterializationOwnershipRecord>,
3984        persistence_warning: Option<String>,
3985        input_settle_timeout_ms: u64,
3986    ) -> Self {
3987        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3988        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3989        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3990        let (harness_mcp_proxy_event_tx, _) =
3991            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3992        let record_providers = records
3993            .iter()
3994            .map(|record| (record.record_id.clone(), record.provider.clone()))
3995            .collect();
3996        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3997            .iter()
3998            .map(|workspace| {
3999                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4000            })
4001            .collect();
4002        for lease in &managed_worktrees {
4003            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4004        }
4005        let managed_worktree_profiles = workspaces.iter()
4006            .map(|workspace| (
4007                workspace.workspace_id.clone(),
4008                workspace.managed_worktree_profiles.clone(),
4009            ))
4010            .collect();
4011        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4012            managed_worktrees,
4013            managed_worktree_tombstones,
4014        ).expect("durable managed worktree registry was validated while loading");
4015        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4016        managed_worktrees.reattach_record_holders(
4017            &managed_worktree_record_holders(&records),
4018            unix_time_ms(),
4019        );
4020        Self {
4021            handle,
4022            access_token,
4023            node_id,
4024            incarnation_id,
4025            started_at_unix_ms: std::time::SystemTime::now()
4026                .duration_since(std::time::UNIX_EPOCH)
4027                .unwrap_or_default()
4028                .as_millis()
4029                .min(u64::MAX as u128) as u64,
4030            workspaces: RwLock::new(
4031                workspaces
4032                    .into_iter()
4033                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4034                    .collect(),
4035            ),
4036            worktree_service_modes,
4037            managed_worktree_profiles,
4038            managed_worktrees: Mutex::new(managed_worktrees),
4039            enabled_providers,
4040            provider_runtime_statuses,
4041            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4042            provider_runtime_monitor,
4043            provider_contracts,
4044            provider_adapter_contracts,
4045            spawn_profiles,
4046            environment_profiles: RwLock::new(BTreeMap::new()),
4047            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4048            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4049            #[cfg(feature = "dig2-station-probe")]
4050            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4051            bundle_catalog: RwLock::new(BundleCatalog::default()),
4052            delivery_store: Mutex::new(None),
4053            context_pack_store: Mutex::new(None),
4054            context_catalog: RwLock::new(ContextPackCatalog::default()),
4055            native_launch_profile_control,
4056            harness_mcp_registry: None,
4057            native_session_catalog,
4058            session_environment_materializer,
4059            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4060            m4a_live: Mutex::new(BTreeMap::new()),
4061            materializations: Mutex::new(
4062                materializations
4063                    .into_iter()
4064                    .map(|record| (record.id().clone(), record))
4065                    .collect(),
4066            ),
4067            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4068                entries: BTreeMap::new(),
4069            }),
4070            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4071            controller: Mutex::new(None),
4072            provisional_spawns: Mutex::new(BTreeMap::new()),
4073            history: Mutex::new(NodeEventHistory::new(record_providers)),
4074            event_tx,
4075            terminal_event_tx,
4076            agent_stream_event_tx,
4077            harness_mcp_proxy_event_tx,
4078            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4079            pty_input_wake: tokio::sync::Notify::new(),
4080            pty_input_marks: Mutex::new(BTreeMap::new()),
4081            next_connection_id: AtomicU64::new(1),
4082            next_instance_id: AtomicU64::new(1),
4083            next_command_id: AtomicU64::new(1),
4084            shutdown: AtomicBool::new(false),
4085            shutdown_notify: Notify::new(),
4086            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4087            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4088            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4089            mutation_gate: AsyncMutex::new(()),
4090            session_bindings: Mutex::new(BTreeMap::new()),
4091            session_records: Mutex::new(SessionRecords {
4092                records: records
4093                    .into_iter()
4094                    .map(|record| (record.record_id.clone(), record))
4095                    .collect(),
4096            }),
4097            state_path,
4098            persistence_error: RwLock::new(
4099                persistence_warning
4100                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4101            ),
4102            state_transaction: Mutex::new(()),
4103            input_settle_timeout_ms,
4104            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4105            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4106            shell_efficiency_profile: OnceLock::new(),
4107            native_session_gauge: AtomicUsize::new(0),
4108            connection_loop_iterations: AtomicU64::new(0),
4109            connection_events_sent: AtomicU64::new(0),
4110            rejected_commands_total: AtomicU64::new(0),
4111            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4112            drive_loop_iterations_total: AtomicU64::new(0),
4113            drive_loop_iterations_idle: AtomicU64::new(0),
4114            session_record_retention: SessionRecordRetentionConfig::default(),
4115            retired_records_total: AtomicU64::new(0),
4116            #[cfg(feature = "fixture")]
4117            fixture_semantic_hook_policy: false,
4118            #[cfg(feature = "fixture")]
4119            fixture_spawn_managed_worktree_v2_failure_probe:
4120                SpawnManagedWorktreeV2FailureProbe::default(),
4121        }
4122    }
4123
4124    #[cfg(test)]
4125    fn new(
4126        handle: Gate4AgentHandle,
4127        access_token: String,
4128        node_id: NodeId,
4129        workspaces: Vec<WorkspaceConfig>,
4130        enabled_providers: Vec<AgentId>,
4131    ) -> Self {
4132        Self::new_with_incarnation(
4133            handle,
4134            access_token,
4135            node_id,
4136            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4137            workspaces,
4138            enabled_providers,
4139            ProviderRuntimeStatuses::default(),
4140            None,
4141            Vec::new(),
4142            Vec::new(),
4143            SpawnProfileRegistry::default(),
4144            None,
4145            None,
4146            None,
4147            None,
4148            Vec::new(),
4149            Vec::new(),
4150            Vec::new(),
4151            Vec::new(),
4152            None,
4153            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4154        )
4155    }
4156
4157    fn persistence_error(&self) -> Option<String> {
4158        self.persistence_error
4159            .read()
4160            .unwrap_or_else(|poisoned| poisoned.into_inner())
4161            .clone()
4162    }
4163
4164    async fn admit_provider_runtime(
4165        &self,
4166        provider: &AgentId,
4167        requirement: ProviderRuntimeRequirement,
4168    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4169        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4170            let pipe_transport = monitor.supports_pipe_transport(provider);
4171            let acp_transport = monitor.supports_acp_transport(provider);
4172            let provider = provider.clone();
4173            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4174            let (status, admission) = timeout(
4175                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4176                refresh,
4177            )
4178            .await
4179            .map_err(|_| failure(
4180                NodeFailureCode::BackendBusy,
4181                "provider runtime probe exceeded its bounded deadline",
4182            ))?
4183            .map_err(|_| failure(
4184                NodeFailureCode::BackendOperationFailed,
4185                "provider runtime probe task failed",
4186            ))?;
4187            if let Some(status) = status {
4188                self
4189                    .provider_runtime_status_updates
4190                    .write()
4191                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4192                    .insert(status.provider().clone(), status);
4193            }
4194            admission.and_then(|policy| {
4195                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4196            })
4197        } else {
4198            // No live monitor means no catalog to consult -- there is
4199            // nothing here to confirm this provider declares a Pipe or ACP
4200            // transport, so Inline/Acp are rejected the same conservative
4201            // way an undeclared provider always was, rather than admitted
4202            // on trust.
4203            crate::provider_runtime::admit_status(
4204                &self.provider_runtime_statuses,
4205                provider,
4206                false,
4207                false,
4208                requirement,
4209            )
4210        };
4211        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4212        admission.map_err(|error| match error {
4213            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4214                NodeFailureCode::BackendOperationFailed,
4215                "provider launcher is unavailable",
4216            ),
4217            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4218                NodeFailureCode::UnsupportedCapability,
4219                "provider semantic capability is not verified",
4220            ),
4221            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4222                NodeFailureCode::BackendBusy,
4223                "provider runtime probe is already in progress",
4224            ),
4225        })
4226    }
4227
4228    /// Whether the catalog declares a Pipe transport for `provider` --
4229    /// the fact `require_policy` needs to admit or reject
4230    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4231    /// catalog to consult, so this stays conservative and rejects, matching
4232    /// `admit_provider_runtime`'s own no-monitor branch.
4233    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4234        match self.provider_runtime_monitor.as_deref() {
4235            Some(monitor) => monitor.supports_pipe_transport(provider),
4236            None => false,
4237        }
4238    }
4239
4240    /// Whether the catalog declares an ACP transport for `provider` -- the
4241    /// fact `require_policy` needs to admit or reject
4242    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4243    /// `provider_supports_pipe_transport` exactly, including the
4244    /// conservative no-monitor default.
4245    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4246        match self.provider_runtime_monitor.as_deref() {
4247            Some(monitor) => monitor.supports_acp_transport(provider),
4248            None => false,
4249        }
4250    }
4251
4252    /// Grants hook ingestion to a provider whose catalog entry declares a
4253    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4254    ///
4255    /// `admit_status`'s `RawPassthrough` arm hands back
4256    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4257    /// correct for everything it was written to answer, and wrong for hooks
4258    /// specifically. A hook event is not inferred from terminal behaviour,
4259    /// so it has nothing to do with whether a launcher probe verified this
4260    /// CLI version's PTY contract; the authority is the catalog declaring
4261    /// the adapter plus the ingress authenticating the route. Without this
4262    /// the node installs a provider's hooks, the CLI faithfully calls them,
4263    /// and every event is refused -- which is exactly the state grok was in.
4264    ///
4265    /// Applied to both admission paths so the monitor's own derivation and
4266    /// this coarse fallback cannot disagree about the same provider.
4267    fn admit_declared_hook_semantics(
4268        &self,
4269        provider: &AgentId,
4270        policy: ProviderRuntimePolicy,
4271    ) -> ProviderRuntimePolicy {
4272        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4273            return policy;
4274        }
4275        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4276            contract.provider == *provider && contract.family == AdapterFamily::Hook
4277        });
4278        if !declares_hook {
4279            return policy;
4280        }
4281        ProviderRuntimePolicy::new(
4282            policy.raw_pty_lifecycle,
4283            policy.semantic_readiness,
4284            policy.structured_prompt,
4285            policy.provider_session_identity,
4286            policy.semantic_resume,
4287            true,
4288        )
4289        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4290    }
4291
4292    /// Station network allowlist → provider launch overlay argv.
4293    ///
4294    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4295    ///   `provider_native` under Moderate (PTY or ACP).
4296    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4297    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4298    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4299    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4300    ///   no overlay.
4301    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4302    ///   empty overlay when no `provider_native`; invented native keys already
4303    ///   refuse at load / resolve.
4304    ///
4305    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4306    fn resolve_network_allowlist_overlay_args(
4307        &self,
4308        provider: &AgentId,
4309        mode: SessionMode,
4310        approval_level: ApprovalLevel,
4311        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4312        provider_home: Option<&Path>,
4313    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4314        let Some(allowlist_id) = network_allowlist else {
4315            return Ok(Vec::new());
4316        };
4317        let catalog = self
4318            .network_allowlist_catalog
4319            .read()
4320            .unwrap_or_else(|poisoned| poisoned.into_inner());
4321        let Some(entry) = catalog.get(allowlist_id) else {
4322            return Err(failure(
4323                NodeFailureCode::UnknownNetworkAllowlist,
4324                "spawn network allowlist is unavailable on this node",
4325            ));
4326        };
4327
4328        // Claude: settings-shaped overlay from permits (not provider_native).
4329        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4330            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4331                |message| {
4332                    failure(
4333                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4334                        &message,
4335                    )
4336                },
4337            )?;
4338            if !matches!(mode, SessionMode::Pty) {
4339                return Err(failure(
4340                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4341                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4342                ));
4343            }
4344            let Some(home) = provider_home else {
4345                return Err(failure(
4346                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4347                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4348                ));
4349            };
4350            let domains =
4351                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4352            if domains.is_empty() {
4353                return Err(failure(
4354                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4355                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4356                ));
4357            }
4358            let settings_path = home.join(
4359                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4360            );
4361            let json =
4362                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4363            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4364                |_| {
4365                    failure(
4366                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4367                        "Claude station network settings sidecar could not be written under ProviderHome",
4368                    )
4369                },
4370            )?;
4371            let overlay =
4372                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4373            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4374        }
4375
4376        let Some(native) = entry.provider_native.as_ref() else {
4377            return Ok(Vec::new());
4378        };
4379        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4380            provider.as_str(),
4381            approval_level,
4382            native,
4383        )
4384        .map_err(|message| {
4385            failure(
4386                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4387                &message,
4388            )
4389        })?;
4390        if overlay.is_empty() {
4391            return Ok(Vec::new());
4392        }
4393        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4394            return Err(failure(
4395                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4396                "Codex network_access -c overlay requires PTY or ACP transport",
4397            ));
4398        }
4399        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4400    }
4401
4402    /// Backward-compatible alias used by Codex-focused unit tests.
4403    fn resolve_codex_network_access_overlay_args(
4404        &self,
4405        provider: &AgentId,
4406        mode: SessionMode,
4407        approval_level: ApprovalLevel,
4408        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4409    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4410        self.resolve_network_allowlist_overlay_args(
4411            provider,
4412            mode,
4413            approval_level,
4414            network_allowlist,
4415            None,
4416        )
4417    }
4418
4419    fn resolve_environment_profile(
4420        &self,
4421        resolved: &ResolvedSpawnSpec,
4422    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4423        // Station network allowlist catalog enforce (plan
4424        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4425        // catalog — unknown ids refuse rather than silent ambient. Runs even
4426        // when no environment_profile_id is present so a bare allowlist still
4427        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4428        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4429            let catalog = self
4430                .network_allowlist_catalog
4431                .read()
4432                .unwrap_or_else(|poisoned| poisoned.into_inner());
4433            let Some(entry) = catalog.get(allowlist_id) else {
4434                return Err(failure(
4435                    NodeFailureCode::UnknownNetworkAllowlist,
4436                    "spawn network allowlist is unavailable on this node",
4437                ));
4438            };
4439            // Track B: refuse provider-native mappings this provider cannot
4440            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4441            if let Some(native) = entry.provider_native.as_ref() {
4442                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4443                    resolved.provider.as_str(),
4444                    resolved.approval_level,
4445                    native,
4446                ) {
4447                    return Err(failure(
4448                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4449                        &message,
4450                    ));
4451                }
4452            }
4453        }
4454        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4455        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4456        // cheap named-pipe path reachability must succeed (Windows
4457        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4458        // NodeFailureCode. Runs even without environment_profile_id (same
4459        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4460        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4461        // acquired later in spawn_session_with_deadline (after instance id).
4462        #[cfg(feature = "dig2-station-probe")]
4463        if resolved.browser_profile_id.is_some() {
4464            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4465            match probe_station_reachable() {
4466                Ok(()) => {}
4467                Err(StationProbeError::UnsupportedPlatform)
4468                | Err(StationProbeError::InvalidPipeSuffix) => {
4469                    return Err(failure(
4470                        NodeFailureCode::BrowserStationProbeUnavailable,
4471                        "dig2browser station probe unavailable on this platform",
4472                    ));
4473                }
4474                Err(StationProbeError::Unreachable) => {
4475                    return Err(failure(
4476                        NodeFailureCode::BrowserStationUnreachable,
4477                        "dig2browser station named-pipe is unreachable",
4478                    ));
4479                }
4480            }
4481        }
4482        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4483            return Ok(None);
4484        };
4485        let profiles = self
4486            .environment_profiles
4487            .read()
4488            .unwrap_or_else(|poisoned| poisoned.into_inner());
4489        let binding = profiles.get(profile_id).ok_or_else(|| {
4490            failure(
4491                NodeFailureCode::UnknownEnvironmentProfile,
4492                "spawn environment profile is unavailable on this node",
4493            )
4494        })?;
4495        if binding.provider != resolved.provider
4496            || binding.native_profile_id(resolved.mode).is_none()
4497        {
4498            return Err(failure(
4499                NodeFailureCode::EnvironmentProfileBindingMismatch,
4500                "spawn environment profile does not match the provider and mode",
4501            ));
4502        }
4503        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4504        // after catalog + optional dig2 probe above. Empty/whitespace ids
4505        // already refuse at Spawn*Id construction.
4506        Ok(Some(ResolvedEnvironmentProfileReceipt {
4507            profile_id: binding.id.clone(),
4508            profile_revision: binding.revision.clone(),
4509            network_allowlist: resolved.network_allowlist.clone(),
4510            browser_profile_id: resolved.browser_profile_id.clone(),
4511        }))
4512    }
4513
4514    fn resolve_bundle(
4515        &self,
4516        resolved: &ResolvedSpawnSpec,
4517        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4518    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4519        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4520            return Ok(None);
4521        };
4522        let catalog = self.bundle_catalog
4523            .read()
4524            .unwrap_or_else(|poisoned| poisoned.into_inner());
4525        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4526                failure(
4527                    NodeFailureCode::UnknownBundle,
4528                    "spawn bundle is unavailable on this node",
4529                )
4530            })?;
4531        self.bundle_layout(
4532            &resolved.provider,
4533            resolved.mode,
4534            environment_profile,
4535            bundle,
4536        )?;
4537        Ok(Some(bundle.receipt()))
4538    }
4539
4540    fn begin_delivery_stage(
4541        &self,
4542        manifest: DeliveryBundleManifestV2,
4543    ) -> Result<NodeResponse, NodeFailure> {
4544        let mut store = self
4545            .delivery_store
4546            .lock()
4547            .unwrap_or_else(|poisoned| poisoned.into_inner());
4548        let store = store.as_mut().ok_or_else(|| {
4549            failure(
4550                NodeFailureCode::DeliveryStageStorageFailed,
4551                "node delivery store is unavailable",
4552            )
4553        })?;
4554        let begun = store.begin(manifest).map_err(delivery_failure)?;
4555        Ok(NodeResponse::DeliveryStageBegun {
4556            stage_id: begun.stage_id,
4557            manifest_digest: begun.manifest_digest,
4558            missing_blobs: begun.missing_blobs,
4559        })
4560    }
4561
4562    fn put_delivery_blob_chunk(
4563        &self,
4564        stage_id: DeliveryStageId,
4565        blob_digest: DeliveryBlobDigestV1,
4566        offset: u64,
4567        chunk_hex: DeliveryBlobChunkHexV1,
4568    ) -> Result<NodeResponse, NodeFailure> {
4569        let mut store = self
4570            .delivery_store
4571            .lock()
4572            .unwrap_or_else(|poisoned| poisoned.into_inner());
4573        let store = store.as_mut().ok_or_else(|| {
4574            failure(
4575                NodeFailureCode::DeliveryStageStorageFailed,
4576                "node delivery store is unavailable",
4577            )
4578        })?;
4579        let accepted = store
4580            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4581            .map_err(delivery_failure)?;
4582        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4583            stage_id: accepted.stage_id,
4584            blob_digest: accepted.blob_digest,
4585            next_offset: accepted.next_offset,
4586        })
4587    }
4588
4589    fn commit_delivery_stage(
4590        &self,
4591        stage_id: DeliveryStageId,
4592    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4593        let mut store = self
4594            .delivery_store
4595            .lock()
4596            .unwrap_or_else(|poisoned| poisoned.into_inner());
4597        let store = store.as_mut().ok_or_else(|| {
4598            failure(
4599                NodeFailureCode::DeliveryStageStorageFailed,
4600                "node delivery store is unavailable",
4601            )
4602        })?;
4603        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4604        let mut catalog = self
4605            .bundle_catalog
4606            .write()
4607            .unwrap_or_else(|poisoned| poisoned.into_inner());
4608        let mut next_catalog = catalog.clone();
4609        next_catalog
4610            .insert_idempotent(prepared.bundle().clone())
4611            .map_err(|_| {
4612                failure(
4613                    NodeFailureCode::DeliveryStageConflict,
4614                    "delivery bundle identity conflicts with installed content",
4615                )
4616            })?;
4617        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4618        *catalog = next_catalog;
4619        Ok(receipt)
4620    }
4621
4622    fn abort_delivery_stage(
4623        &self,
4624        stage_id: &DeliveryStageId,
4625    ) -> Result<(), NodeFailure> {
4626        let mut store = self
4627            .delivery_store
4628            .lock()
4629            .unwrap_or_else(|poisoned| poisoned.into_inner());
4630        let store = store.as_mut().ok_or_else(|| {
4631            failure(
4632                NodeFailureCode::DeliveryStageStorageFailed,
4633                "node delivery store is unavailable",
4634            )
4635        })?;
4636        store.abort(stage_id).map_err(delivery_failure)
4637    }
4638
4639    fn resolve_context(
4640        &self,
4641        resolved: &ResolvedSpawnSpec,
4642    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4643        let Some(context_id) = resolved.context_id.as_ref() else {
4644            return Ok(None);
4645        };
4646        self.context_catalog
4647            .read()
4648            .unwrap_or_else(|poisoned| poisoned.into_inner())
4649            .get(context_id)
4650            .map(|context| context.receipt().clone())
4651            .ok_or_else(|| {
4652                failure(
4653                    NodeFailureCode::UnknownContextPack,
4654                    "spawn context pack is unavailable on this node",
4655                )
4656            })
4657            .map(Some)
4658    }
4659
4660    fn bundle_layout(
4661        &self,
4662        provider: &AgentId,
4663        mode: SessionMode,
4664        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4665        bundle: &NodeBundle,
4666    ) -> Result<BundleProviderLayout, NodeFailure> {
4667        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4668            failure(
4669                NodeFailureCode::BundleBindingMismatch,
4670                "spawn bundle does not match the provider and mode",
4671            )
4672        })?;
4673        if layout != BundleProviderLayout::Codex {
4674            return Ok(layout);
4675        }
4676        let profile = environment_profile.and_then(|receipt| {
4677            let profiles = self
4678                .environment_profiles
4679                .read()
4680                .unwrap_or_else(|poisoned| poisoned.into_inner());
4681            let binding = profiles.get(&receipt.profile_id)?;
4682            if binding.revision != receipt.profile_revision
4683                || &binding.provider != provider
4684                || binding.native_profile_id(mode).is_none()
4685            {
4686                return None;
4687            }
4688            self.environment_materialization_profiles
4689                .read()
4690                .unwrap_or_else(|poisoned| poisoned.into_inner())
4691                .get(&receipt.profile_id)
4692                .cloned()
4693        });
4694        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4695            return Err(failure(
4696                NodeFailureCode::BundleBindingMismatch,
4697                "Codex bundle requires an exact isolated CODEX_HOME profile",
4698            ));
4699        }
4700        Ok(layout)
4701    }
4702
4703    fn select_environment_profile(
4704        &self,
4705        instance_id: AgentInstanceId,
4706        provider: &AgentId,
4707        mode: SessionMode,
4708        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4709    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4710        let Some(environment_profile) = environment_profile else {
4711            return Ok(None);
4712        };
4713        let native_profile_id = {
4714            let profiles = self
4715                .environment_profiles
4716                .read()
4717                .unwrap_or_else(|poisoned| poisoned.into_inner());
4718            let binding = profiles
4719                .get(&environment_profile.profile_id)
4720                .ok_or_else(|| {
4721                    failure(
4722                        NodeFailureCode::UnknownEnvironmentProfile,
4723                        "session environment profile is unavailable on this node",
4724                    )
4725                })?;
4726            if &binding.revision != &environment_profile.profile_revision
4727                || &binding.provider != provider
4728            {
4729                return Err(failure(
4730                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4731                    "session environment profile revision or provider changed",
4732                ));
4733            }
4734            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4735                failure(
4736                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4737                    "session environment profile does not support this mode",
4738                )
4739            })?
4740        };
4741        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4742            failure(
4743                NodeFailureCode::BackendOperationFailed,
4744                "native environment profile controller is unavailable",
4745            )
4746        })?;
4747        control
4748            .select_native_launch_profile(instance_id, native_profile_id)
4749            .map_err(|_| {
4750                failure(
4751                    NodeFailureCode::BackendOperationFailed,
4752                    "native environment profile selection failed",
4753                )
4754            })?;
4755        Ok(Some(NativeEnvironmentSelectionGuard {
4756            control: Some(control),
4757            instance_id,
4758        }))
4759    }
4760
4761    fn install_prepared_launch_overlay(
4762        &self,
4763        instance_id: AgentInstanceId,
4764        overlay: PreparedNativeLaunchOverlay,
4765    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4766        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4767            failure(
4768                NodeFailureCode::BackendOperationFailed,
4769                "native launch profile controller is unavailable",
4770            )
4771        })?;
4772        match overlay {
4773            PreparedNativeLaunchOverlay::Environment(overlay) => {
4774                control
4775                    .install_native_launch_environment_overlay(instance_id, overlay)
4776                    .map_err(|_| failure(
4777                        NodeFailureCode::BackendOperationFailed,
4778                        "session environment overlay installation failed",
4779                    ))?;
4780                Ok(None)
4781            }
4782            PreparedNativeLaunchOverlay::Instance(overlay) => {
4783                control
4784                    .install_native_instance_launch_overlay(instance_id, overlay)
4785                    .map_err(|_| failure(
4786                        NodeFailureCode::BundleBindingMismatch,
4787                        "session bundle launch overlay installation failed",
4788                    ))?;
4789                Ok(Some(NativeInstanceOverlayGuard {
4790                    control: Some(control),
4791                    instance_id,
4792                }))
4793            }
4794        }
4795    }
4796
4797    fn environment_materialization_profile(
4798        &self,
4799        environment_profile: &ResolvedEnvironmentProfileReceipt,
4800    ) -> Option<NodeSessionMaterializationProfile> {
4801        self.environment_materialization_profiles
4802            .read()
4803            .unwrap_or_else(|poisoned| poisoned.into_inner())
4804            .get(&environment_profile.profile_id)
4805            .cloned()
4806    }
4807
4808    fn materialization_profile(
4809        &self,
4810        provider: &AgentId,
4811        mode: SessionMode,
4812        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4813        bundle: Option<&ResolvedBundleReceipt>,
4814        context: Option<&ResolvedContextPackReceipt>,
4815    ) -> Result<Option<(
4816        NodeSessionMaterializationProfile,
4817        bool,
4818        Option<BundleProviderLayout>,
4819    )>, NodeFailure> {
4820        let environment = environment_profile
4821            .and_then(|receipt| self.environment_materialization_profile(receipt));
4822        let (bundle, bundle_layout) = match bundle {
4823            Some(receipt) => {
4824                let catalog = self.bundle_catalog.read()
4825                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4826                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4827                    NodeFailureCode::UnknownBundle,
4828                    "session bundle is unavailable on this node",
4829                ))?;
4830                if installed.receipt() != *receipt {
4831                    return Err(failure(
4832                        NodeFailureCode::BundleBindingMismatch,
4833                        "session bundle revision or digest changed",
4834                    ));
4835                }
4836                let layout = self.bundle_layout(
4837                    provider,
4838                    mode,
4839                    environment_profile,
4840                    installed,
4841                )?;
4842                (Some(installed.clone()), Some(layout))
4843            }
4844            None => (None, None),
4845        };
4846        let has_environment_overlay = environment.is_some() || context.is_some();
4847        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4848            (None, None, None) => None,
4849            (Some(profile), None, None) => Some(Ok(profile)),
4850            (None, Some(bundle), Some(layout)) => {
4851                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4852            }
4853            (Some(profile), Some(bundle), Some(layout)) => {
4854                Some(profile.with_bundle(bundle, layout))
4855            }
4856            _ => {
4857                return Err(failure(
4858                    NodeFailureCode::BundleBindingMismatch,
4859                    "session bundle provider layout is unavailable",
4860                ));
4861            }
4862        };
4863        let profile = profile
4864            .transpose()
4865            .map_err(|_| failure(
4866                NodeFailureCode::BundleMaterializationFailed,
4867                "session bundle materialization profile is invalid",
4868            ))?;
4869        let profile = match (profile, context) {
4870            (Some(profile), Some(receipt)) => {
4871                let catalog = self.context_catalog
4872                    .read()
4873                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4874                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4875                    NodeFailureCode::UnknownContextPack,
4876                    "session context pack is unavailable on this node",
4877                ))?;
4878                if pack.receipt() != receipt {
4879                    return Err(failure(
4880                        NodeFailureCode::ContextPackMaterializationFailed,
4881                        "session context pack identity changed",
4882                    ));
4883                }
4884                profile.with_context(pack)
4885            }
4886            (None, Some(receipt)) => {
4887                let catalog = self.context_catalog
4888                    .read()
4889                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4890                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4891                    NodeFailureCode::UnknownContextPack,
4892                    "session context pack is unavailable on this node",
4893                ))?;
4894                if pack.receipt() != receipt {
4895                    return Err(failure(
4896                        NodeFailureCode::ContextPackMaterializationFailed,
4897                        "session context pack identity changed",
4898                    ));
4899                }
4900                NodeSessionMaterializationProfile::from_context(pack)
4901            }
4902            (Some(profile), None) => Ok(profile),
4903            (None, None) => return Ok(None),
4904        }
4905        .map_err(|_| failure(
4906            NodeFailureCode::ContextPackMaterializationFailed,
4907            "session context pack materialization profile is invalid",
4908        ))?;
4909        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4910    }
4911
4912    fn prepare_session_materialization<'a>(
4913        &'a self,
4914        address: &SessionAddress,
4915        provider: &AgentId,
4916        mode: SessionMode,
4917        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4918        bundle: Option<&ResolvedBundleReceipt>,
4919        context: Option<&ResolvedContextPackReceipt>,
4920        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4921    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4922        let Some((profile, has_environment_overlay, bundle_layout)) =
4923            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4924        else {
4925            return Ok(None);
4926        };
4927        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4928            failure(
4929                NodeFailureCode::BackendOperationFailed,
4930                "session environment materializer is unavailable",
4931            )
4932        })?;
4933        let materialization_failure_code = if context.is_some() {
4934            NodeFailureCode::ContextPackMaterializationFailed
4935        } else if bundle.is_some() {
4936            NodeFailureCode::BundleMaterializationFailed
4937        } else {
4938            NodeFailureCode::BackendOperationFailed
4939        };
4940        let id = MaterializationId::new(format!(
4941            "mat-{}-{}-{}",
4942            self.incarnation_id,
4943            address.session.instance_id.0,
4944            address.session.generation.0,
4945        ))
4946        .map_err(|_| {
4947            failure(
4948                NodeFailureCode::BackendOperationFailed,
4949                "session environment materialization identity failed",
4950            )
4951        })?;
4952        let owner = MaterializationOwner::Session {
4953            incarnation_id: self.incarnation_id,
4954            instance_id: address.session.instance_id,
4955            generation: address.session.generation,
4956        };
4957        let mut ownership = materializer
4958            .begin(
4959                id.clone(),
4960                environment_profile.cloned(),
4961                bundle.cloned(),
4962                context.cloned(),
4963                owner,
4964                managed_lease_id,
4965                &profile,
4966                unix_time_ms(),
4967            )
4968            .map_err(|_| {
4969                failure(
4970                    materialization_failure_code,
4971                    "session materialization preparation failed",
4972                )
4973            })?;
4974        {
4975            let _transaction = self
4976                .state_transaction
4977                .lock()
4978                .unwrap_or_else(|poisoned| poisoned.into_inner());
4979            let duplicate = self
4980                .materializations
4981                .lock()
4982                .unwrap_or_else(|poisoned| poisoned.into_inner())
4983                .insert(id.clone(), ownership.clone());
4984            if duplicate.is_some() {
4985                return Err(failure(
4986                    NodeFailureCode::BackendOperationFailed,
4987                    "session environment materialization identity conflict",
4988                ));
4989            }
4990            if let Err(error) = self.persist_state_locked() {
4991                self.materializations
4992                    .lock()
4993                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4994                    .remove(&id);
4995                return Err(persistence_failure(error));
4996            }
4997        }
4998        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4999            Ok(environment) => environment,
5000            Err(_) => {
5001                let transaction = self
5002                    .state_transaction
5003                    .lock()
5004                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5005                self.materializations
5006                    .lock()
5007                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5008                    .insert(id.clone(), ownership.clone());
5009                if self.persist_state_locked().is_err() {
5010                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5011                }
5012                drop(transaction);
5013                if ownership.state() == MaterializationState::CleanupRequired {
5014                    let _ = self.cleanup_materialization(&id);
5015                }
5016                return Err(failure(
5017                    materialization_failure_code,
5018                    "session materialization failed",
5019                ));
5020            }
5021        };
5022        {
5023            let _transaction = self
5024                .state_transaction
5025                .lock()
5026                .unwrap_or_else(|poisoned| poisoned.into_inner());
5027            self.materializations
5028                .lock()
5029                .unwrap_or_else(|poisoned| poisoned.into_inner())
5030                .insert(id.clone(), ownership.clone());
5031            if let Err(error) = self.persist_state_locked() {
5032                drop(_transaction);
5033                let _ = self.cleanup_materialization(&id);
5034                return Err(persistence_failure(error));
5035            }
5036        }
5037        let guard = SessionMaterializationGuard {
5038            shared: self,
5039            id: Some(id),
5040            provider_home: Some(ownership.provider_home().to_path_buf()),
5041        };
5042        let bundle_arguments = match bundle {
5043            Some(receipt) => {
5044                let catalog = self.bundle_catalog.read()
5045                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5046                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5047                    NodeFailureCode::UnknownBundle,
5048                    "session bundle is unavailable on this node",
5049                ))?;
5050                if installed.receipt() != *receipt {
5051                    return Err(failure(
5052                        NodeFailureCode::BundleBindingMismatch,
5053                        "session bundle revision or digest changed",
5054                    ));
5055                }
5056                let layout = bundle_layout.ok_or_else(|| failure(
5057                    NodeFailureCode::BundleBindingMismatch,
5058                    "materialized bundle provider layout is unavailable",
5059                ))?;
5060                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5061                    failure(
5062                        NodeFailureCode::BundleMaterializationFailed,
5063                        "session bundle byte revalidation failed",
5064                    )
5065                })?;
5066                Some(bundle_launch_arguments(
5067                    layout,
5068                    ownership.bundle_root(),
5069                    ownership.provider_home(),
5070                ).map_err(|_| failure(
5071                    NodeFailureCode::BundleBindingMismatch,
5072                    "session bundle launch binding is invalid",
5073                ))?)
5074            }
5075            None => None,
5076        };
5077        if let Some(receipt) = context {
5078            materializer
5079                .revalidate_context(&ownership, receipt)
5080                .map_err(|_| failure(
5081                    NodeFailureCode::ContextPackMaterializationFailed,
5082                    "session context pack byte revalidation failed",
5083                ))?;
5084        }
5085        let transport = match mode {
5086            SessionMode::Pty => TransportKind::Pty,
5087            SessionMode::Inline => TransportKind::Pipe,
5088            SessionMode::Acp => TransportKind::Acp,
5089        };
5090        let overlay = if let Some(extra_args) = bundle_arguments {
5091            Some(PreparedNativeLaunchOverlay::Instance(
5092                NativeInstanceLaunchOverlay::new(
5093                    provider.clone(),
5094                    transport,
5095                    if has_environment_overlay {
5096                        environment
5097                    } else {
5098                        Vec::new()
5099                    },
5100                    extra_args,
5101                )
5102                .map_err(|_| {
5103                    failure(
5104                        NodeFailureCode::BundleBindingMismatch,
5105                        "session bundle launch overlay is invalid",
5106                    )
5107                })?,
5108            ))
5109        } else if has_environment_overlay {
5110            let context_only = environment_profile.is_none()
5111                && bundle.is_none()
5112                && context.is_some();
5113            let overlay = if context_only {
5114                NativeLaunchEnvironmentOverlay::new_context_root(
5115                    provider.clone(),
5116                    transport,
5117                    environment,
5118                )
5119                .map_err(|_| {
5120                    failure(
5121                        NodeFailureCode::ContextPackMaterializationFailed,
5122                        "context-only launch overlay is invalid",
5123                    )
5124                })?
5125            } else {
5126                NativeLaunchEnvironmentOverlay::new(
5127                    provider.clone(),
5128                    transport,
5129                    environment,
5130                )
5131                .map_err(|_| {
5132                    failure(
5133                        NodeFailureCode::BackendOperationFailed,
5134                        "session environment overlay is invalid",
5135                    )
5136                })?
5137            };
5138            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5139        } else {
5140            None
5141        };
5142        Ok(Some((overlay, guard)))
5143    }
5144
5145    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5146        let Some(mut ownership) = self
5147            .materializations
5148            .lock()
5149            .unwrap_or_else(|poisoned| poisoned.into_inner())
5150            .get(id)
5151            .cloned()
5152        else {
5153            return Ok(());
5154        };
5155        if ownership.state() != MaterializationState::CleanupRequired {
5156            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5157                failure(
5158                    NodeFailureCode::BackendOperationFailed,
5159                    "session environment cleanup state is invalid",
5160                )
5161            })?;
5162        }
5163        {
5164            let _transaction = self
5165                .state_transaction
5166                .lock()
5167                .unwrap_or_else(|poisoned| poisoned.into_inner());
5168            let previous = self
5169                .materializations
5170                .lock()
5171                .unwrap_or_else(|poisoned| poisoned.into_inner())
5172                .insert(id.clone(), ownership.clone());
5173            if let Err(error) = self.persist_state_locked() {
5174                let mut materializations = self
5175                    .materializations
5176                    .lock()
5177                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5178                if let Some(previous) = previous {
5179                    materializations.insert(id.clone(), previous);
5180                }
5181                return Err(persistence_failure(error));
5182            }
5183        }
5184        let cleanup = self
5185            .session_environment_materializer
5186            .as_ref()
5187            .ok_or(())
5188            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5189        if cleanup.is_err() {
5190            let _ = ownership.mark_recovery_required(unix_time_ms());
5191            self.materializations
5192                .lock()
5193                .unwrap_or_else(|poisoned| poisoned.into_inner())
5194                .insert(id.clone(), ownership);
5195            if self.persist_state().is_err() {
5196                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5197            }
5198            return Err(failure(
5199                NodeFailureCode::BackendOperationFailed,
5200                "session environment cleanup requires recovery",
5201            ));
5202        }
5203        let _transaction = self
5204            .state_transaction
5205            .lock()
5206            .unwrap_or_else(|poisoned| poisoned.into_inner());
5207        self.materializations
5208            .lock()
5209            .unwrap_or_else(|poisoned| poisoned.into_inner())
5210            .remove(id);
5211        if let Err(error) = self.persist_state_locked() {
5212            self.materializations
5213                .lock()
5214                .unwrap_or_else(|poisoned| poisoned.into_inner())
5215                .insert(id.clone(), ownership);
5216            return Err(persistence_failure(error));
5217        }
5218        Ok(())
5219    }
5220
5221    fn resolve_record_materialization(
5222        &self,
5223        record_id: &SessionRecordId,
5224        provider: &AgentId,
5225        mode: SessionMode,
5226        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5227        bundle: Option<&ResolvedBundleReceipt>,
5228        context: Option<&ResolvedContextPackReceipt>,
5229    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5230        let ownership = {
5231            let materializations = self
5232                .materializations
5233                .lock()
5234                .unwrap_or_else(|poisoned| poisoned.into_inner());
5235            let mut matches = materializations.values().filter(|record| {
5236                record.owner()
5237                    == &MaterializationOwner::Record {
5238                        record_id: record_id.clone(),
5239                    }
5240            });
5241            let ownership = matches.next().cloned();
5242            if matches.next().is_some() {
5243                return Err(failure(
5244                    NodeFailureCode::BackendOperationFailed,
5245                    "managed session has conflicting environment ownership",
5246                ));
5247            }
5248            ownership
5249        };
5250        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5251            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5252                failure(
5253                    NodeFailureCode::ContextPackMaterializationFailed,
5254                    "session context materializer is unavailable",
5255                )
5256            })?;
5257            let pack = materializer
5258                .revalidate_context(ownership, receipt)
5259                .map_err(|_| {
5260                    self.mark_materialization_recovery_required(ownership.id());
5261                    failure(
5262                        NodeFailureCode::ContextPackMaterializationFailed,
5263                        "managed session context pack revalidation failed",
5264                    )
5265                })?;
5266            self.context_catalog
5267                .write()
5268                .unwrap_or_else(|poisoned| poisoned.into_inner())
5269                .insert(pack)
5270                .map_err(|_| failure(
5271                    NodeFailureCode::ContextPackMaterializationFailed,
5272                    "managed session context pack catalog restore failed",
5273                ))?;
5274        }
5275        let materialization = self.materialization_profile(
5276            provider,
5277            mode,
5278            environment_profile,
5279            bundle,
5280            context,
5281        )?;
5282        match (ownership, materialization) {
5283            (None, None) => Ok(None),
5284            (None, Some(_)) | (Some(_), None) => Err(failure(
5285                NodeFailureCode::BackendOperationFailed,
5286                "managed session materialization is unavailable",
5287            )),
5288            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5289                if ownership.environment_profile() != environment_profile
5290                    || ownership.bundle() != bundle
5291                    || ownership.context() != context
5292                    || ownership.state() != MaterializationState::Ready
5293                {
5294                    self.mark_materialization_recovery_required(ownership.id());
5295                    return Err(failure(
5296                        NodeFailureCode::BackendOperationFailed,
5297                        "managed session environment ownership requires recovery",
5298                    ));
5299                }
5300                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5301                    failure(
5302                        NodeFailureCode::BackendOperationFailed,
5303                        "session environment materializer is unavailable",
5304                    )
5305                })?;
5306                if materializer.revalidate(&ownership).is_err() {
5307                    self.mark_materialization_recovery_required(ownership.id());
5308                    return Err(failure(
5309                        NodeFailureCode::BackendOperationFailed,
5310                        "managed session environment revalidation failed",
5311                    ));
5312                }
5313                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5314                    let catalog = self.bundle_catalog.read()
5315                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5316                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5317                        return Err(failure(
5318                            NodeFailureCode::UnknownBundle,
5319                            "managed session bundle is unavailable",
5320                        ));
5321                    };
5322                    let layout = bundle_layout.ok_or_else(|| failure(
5323                        NodeFailureCode::BundleBindingMismatch,
5324                        "record bundle provider layout is unavailable",
5325                    ))?;
5326                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5327                        drop(catalog);
5328                        self.mark_materialization_recovery_required(ownership.id());
5329                        return Err(failure(
5330                            NodeFailureCode::BundleMaterializationFailed,
5331                            "managed session bundle revalidation failed",
5332                        ));
5333                    }
5334                    Some(bundle_launch_arguments(
5335                        layout,
5336                        ownership.bundle_root(),
5337                        ownership.provider_home(),
5338                    ).map_err(|_| failure(
5339                        NodeFailureCode::BundleBindingMismatch,
5340                        "managed session bundle launch binding is invalid",
5341                    ))?)
5342                } else {
5343                    None
5344                };
5345                if !has_environment_materialization {
5346                    let overlay = bundle_arguments.map(|extra_args| {
5347                        NativeInstanceLaunchOverlay::new(
5348                            provider.clone(),
5349                            TransportKind::Pty,
5350                            Vec::new(),
5351                            extra_args,
5352                        )
5353                        .map(PreparedNativeLaunchOverlay::Instance)
5354                        .map_err(|_| failure(
5355                            NodeFailureCode::BundleBindingMismatch,
5356                            "managed session bundle launch overlay is invalid",
5357                        ))
5358                    }).transpose()?;
5359                    return Ok(Some((ownership.id().clone(), overlay)));
5360                }
5361                let environment = match materializer.resolve_environment(&ownership, &profile) {
5362                    Ok(environment) => environment,
5363                    Err(
5364                        SessionEnvironmentMaterializeError::SecretUnavailable
5365                        | SessionEnvironmentMaterializeError::SecretDenied
5366                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5367                    ) => {
5368                        return Err(failure(
5369                            NodeFailureCode::BackendOperationFailed,
5370                            "managed session environment resolution failed",
5371                        ));
5372                    }
5373                    Err(
5374                        SessionEnvironmentMaterializeError::InvalidRoot
5375                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5376                        | SessionEnvironmentMaterializeError::Filesystem(_)
5377                        | SessionEnvironmentMaterializeError::Record(_),
5378                    ) => {
5379                        self.mark_materialization_recovery_required(ownership.id());
5380                        return Err(failure(
5381                            NodeFailureCode::BackendOperationFailed,
5382                            "managed session environment resolution failed",
5383                        ));
5384                    }
5385                };
5386                let transport = match mode {
5387                    SessionMode::Pty => TransportKind::Pty,
5388                    SessionMode::Inline => TransportKind::Pipe,
5389                    SessionMode::Acp => TransportKind::Acp,
5390                };
5391                let overlay = match bundle_arguments {
5392                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5393                        NativeInstanceLaunchOverlay::new(
5394                            provider.clone(),
5395                            transport,
5396                            environment,
5397                            extra_args,
5398                        )
5399                        .map_err(|_| failure(
5400                            NodeFailureCode::BundleBindingMismatch,
5401                            "managed session bundle launch overlay is invalid",
5402                        ))?,
5403                    ),
5404                    None => PreparedNativeLaunchOverlay::Environment(
5405                        NativeLaunchEnvironmentOverlay::new(
5406                            provider.clone(),
5407                            transport,
5408                            environment,
5409                        )
5410                        .map_err(|_| {
5411                            failure(
5412                                NodeFailureCode::BackendOperationFailed,
5413                                "managed session environment overlay is invalid",
5414                            )
5415                        })?,
5416                    ),
5417                };
5418                Ok(Some((ownership.id().clone(), Some(overlay))))
5419            }
5420        }
5421    }
5422
5423    fn destroy_record_materialization(
5424        &self,
5425        record_id: &SessionRecordId,
5426    ) -> Result<bool, NodeFailure> {
5427        let ids = self
5428            .materializations
5429            .lock()
5430            .unwrap_or_else(|poisoned| poisoned.into_inner())
5431            .values()
5432            .filter_map(|record| {
5433                (record.owner()
5434                    == &MaterializationOwner::Record {
5435                        record_id: record_id.clone(),
5436                    })
5437                .then(|| record.id().clone())
5438            })
5439            .collect::<Vec<_>>();
5440        if ids.len() > 1 {
5441            return Err(failure(
5442                NodeFailureCode::BackendOperationFailed,
5443                "managed session has conflicting environment ownership",
5444            ));
5445        }
5446        let Some(id) = ids.first().cloned() else {
5447            return Ok(false);
5448        };
5449        let (terminal_record, mut cleanup_ownership) = {
5450            let _transaction = self
5451                .state_transaction
5452                .lock()
5453                .unwrap_or_else(|poisoned| poisoned.into_inner());
5454            let original_record = self.record(record_id)?;
5455            let mut terminal_record = original_record.clone();
5456            terminal_record.provider_session = None;
5457            terminal_record.active_session = None;
5458            terminal_record.state = ManagedSessionState::Unavailable;
5459            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5460            terminal_record.updated_at_unix_ms = unix_time_ms();
5461            let original_ownership = self
5462                .materializations
5463                .lock()
5464                .unwrap_or_else(|poisoned| poisoned.into_inner())
5465                .get(&id)
5466                .cloned()
5467                .ok_or_else(|| {
5468                    failure(
5469                        NodeFailureCode::BackendOperationFailed,
5470                        "managed session environment ownership disappeared during cleanup",
5471                    )
5472                })?;
5473            let mut cleanup_ownership = original_ownership.clone();
5474            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5475                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5476                    failure(
5477                        NodeFailureCode::BackendOperationFailed,
5478                        "managed session environment cleanup state is invalid",
5479                    )
5480                })?;
5481            }
5482            self.session_records
5483                .lock()
5484                .unwrap_or_else(|poisoned| poisoned.into_inner())
5485                .records
5486                .insert(record_id.clone(), terminal_record.clone());
5487            self.materializations
5488                .lock()
5489                .unwrap_or_else(|poisoned| poisoned.into_inner())
5490                .insert(id.clone(), cleanup_ownership.clone());
5491            if let Err(error) = self.persist_state_locked() {
5492                self.session_records
5493                    .lock()
5494                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5495                    .records
5496                    .insert(record_id.clone(), original_record);
5497                self.materializations
5498                    .lock()
5499                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5500                    .insert(id.clone(), original_ownership);
5501                return Err(persistence_failure(error));
5502            }
5503            (terminal_record, cleanup_ownership)
5504        };
5505        let cleanup = self
5506            .session_environment_materializer
5507            .as_ref()
5508            .ok_or(())
5509            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5510        if cleanup.is_err() {
5511            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5512            self.materializations
5513                .lock()
5514                .unwrap_or_else(|poisoned| poisoned.into_inner())
5515                .insert(id.clone(), cleanup_ownership);
5516            if self.persist_state().is_err() {
5517                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5518            }
5519            return Err(failure(
5520                NodeFailureCode::BackendOperationFailed,
5521                "managed session environment cleanup requires recovery",
5522            ));
5523        }
5524        let _transaction = self
5525            .state_transaction
5526            .lock()
5527            .unwrap_or_else(|poisoned| poisoned.into_inner());
5528        self.remove_record_memory(record_id);
5529        self.materializations
5530            .lock()
5531            .unwrap_or_else(|poisoned| poisoned.into_inner())
5532            .remove(&id);
5533        if let Err(error) = self.persist_state_locked() {
5534            self.session_records
5535                .lock()
5536                .unwrap_or_else(|poisoned| poisoned.into_inner())
5537                .records
5538                .insert(record_id.clone(), terminal_record);
5539            self.materializations
5540                .lock()
5541                .unwrap_or_else(|poisoned| poisoned.into_inner())
5542                .insert(id, cleanup_ownership);
5543            return Err(persistence_failure(error));
5544        }
5545        Ok(true)
5546    }
5547
5548    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5549        let changed = {
5550            let mut materializations = self
5551                .materializations
5552                .lock()
5553                .unwrap_or_else(|poisoned| poisoned.into_inner());
5554            let Some(ownership) = materializations.get_mut(id) else {
5555                return;
5556            };
5557            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5558        };
5559        if changed && self.persist_state().is_err() {
5560            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5561        }
5562    }
5563
5564    fn reconcile_materializations(&self) {
5565        let records = self
5566            .materializations
5567            .lock()
5568            .unwrap_or_else(|poisoned| poisoned.into_inner())
5569            .values()
5570            .cloned()
5571            .collect::<Vec<_>>();
5572        for mut ownership in records {
5573            let result = match (ownership.state(), ownership.owner()) {
5574                (MaterializationState::Preparing, _)
5575                | (MaterializationState::CleanupRequired, _) => {
5576                    self.cleanup_materialization(ownership.id())
5577                }
5578                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5579                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5580                        self.materializations
5581                            .lock()
5582                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5583                            .insert(ownership.id().clone(), ownership.clone());
5584                        self.persist_state().map_err(persistence_failure)
5585                    } else {
5586                        Err(failure(
5587                            NodeFailureCode::BackendOperationFailed,
5588                            "session environment reconciliation failed",
5589                        ))
5590                    }
5591                }
5592                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5593                    .session_environment_materializer
5594                    .as_ref()
5595                    .ok_or_else(|| {
5596                        failure(
5597                            NodeFailureCode::BackendOperationFailed,
5598                            "session environment materializer is unavailable",
5599                        )
5600                    })
5601                    .and_then(|materializer| {
5602                        materializer.revalidate(&ownership).map_err(|_| failure(
5603                            NodeFailureCode::BackendOperationFailed,
5604                            "session materialization revalidation failed",
5605                        ))?;
5606                        let record = self.record(record_id)?;
5607                        if record.environment_profile.as_ref() != ownership.environment_profile()
5608                            || record.bundle.as_ref() != ownership.bundle()
5609                            || record.context.as_ref() != ownership.context()
5610                        {
5611                            return Err(failure(
5612                                NodeFailureCode::BackendOperationFailed,
5613                                "session materialization ownership changed after restart",
5614                            ));
5615                        }
5616                        if let Some(receipt) = ownership.bundle() {
5617                            let catalog = self.bundle_catalog.read()
5618                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5619                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5620                                NodeFailureCode::UnknownBundle,
5621                                "session bundle is unavailable after restart",
5622                            ))?;
5623                            if bundle.receipt() != *receipt {
5624                                return Err(failure(
5625                                    NodeFailureCode::BundleBindingMismatch,
5626                                    "session bundle changed after restart",
5627                                ));
5628                            }
5629                            let layout = self.bundle_layout(
5630                                &record.provider,
5631                                record.mode,
5632                                record.environment_profile.as_ref(),
5633                                bundle,
5634                            )?;
5635                            materializer
5636                                .revalidate_bundle(&ownership, bundle, layout)
5637                                .map_err(|_| failure(
5638                                    NodeFailureCode::BundleMaterializationFailed,
5639                                    "session bundle revalidation failed",
5640                                ))?;
5641                        }
5642                        if let Some(receipt) = ownership.context() {
5643                            let pack = materializer
5644                                .revalidate_context(&ownership, receipt)
5645                                .map_err(|_| {
5646                                    failure(
5647                                        NodeFailureCode::ContextPackMaterializationFailed,
5648                                        "session context revalidation failed",
5649                                    )
5650                                })?;
5651                            self.context_catalog
5652                                .write()
5653                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5654                                .insert(pack)
5655                                .map_err(|_| {
5656                                    failure(
5657                                        NodeFailureCode::ContextPackMaterializationFailed,
5658                                        "session context catalog restore failed",
5659                                    )
5660                                })?;
5661                        }
5662                        Ok(())
5663                    }),
5664                (MaterializationState::RecoveryRequired, _) => Ok(()),
5665            };
5666            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5667                let _ = ownership.mark_recovery_required(unix_time_ms());
5668                self.materializations
5669                    .lock()
5670                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5671                    .insert(ownership.id().clone(), ownership);
5672                if self.persist_state().is_err() {
5673                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5674                }
5675            }
5676        }
5677        let materialized_profiles = self
5678            .environment_materialization_profiles
5679            .read()
5680            .unwrap_or_else(|poisoned| poisoned.into_inner())
5681            .keys()
5682            .cloned()
5683            .collect::<Vec<_>>();
5684        let ownership = self
5685            .materializations
5686            .lock()
5687            .unwrap_or_else(|poisoned| poisoned.into_inner())
5688            .values()
5689            .cloned()
5690            .collect::<Vec<_>>();
5691        let changed = {
5692            let mut session_records = self
5693                .session_records
5694                .lock()
5695                .unwrap_or_else(|poisoned| poisoned.into_inner());
5696            let mut changed = false;
5697            for record in session_records.records.values_mut() {
5698                let environment_profile = record.environment_profile.as_ref();
5699                if environment_profile.is_none()
5700                    && record.bundle.is_none()
5701                    && record.context.is_none()
5702                {
5703                    continue;
5704                }
5705                if record.bundle.is_none()
5706                    && environment_profile.is_some_and(|profile| {
5707                        !materialized_profiles.contains(&profile.profile_id)
5708                    })
5709                {
5710                    continue;
5711                }
5712                let has_exact_ownership = ownership.iter().any(|candidate| {
5713                    candidate.state() == MaterializationState::Ready
5714                        && candidate.environment_profile() == environment_profile
5715                        && candidate.bundle() == record.bundle.as_ref()
5716                        && candidate.context() == record.context.as_ref()
5717                        && candidate.owner()
5718                            == &MaterializationOwner::Record {
5719                                record_id: record.record_id.clone(),
5720                            }
5721                });
5722                if !has_exact_ownership {
5723                    record.provider_session = None;
5724                    record.active_session = None;
5725                    record.state = ManagedSessionState::Unavailable;
5726                    record.last_error = Some(if record.bundle.is_some() {
5727                        "bundle-unavailable".to_owned()
5728                    } else if record.context.is_some() {
5729                        "context-unavailable".to_owned()
5730                    } else {
5731                        "environment-profile-unavailable".to_owned()
5732                    });
5733                    record.updated_at_unix_ms = unix_time_ms();
5734                    changed = true;
5735                }
5736            }
5737            changed
5738        };
5739        if changed && self.persist_state().is_err() {
5740            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5741        }
5742    }
5743
5744    fn replay_spawn_spec(
5745        &self,
5746        spec: &SpawnSpec,
5747        now: Instant,
5748    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5749        let mut cache = self
5750            .spawn_idempotency
5751            .lock()
5752            .unwrap_or_else(|poisoned| poisoned.into_inner());
5753        cache.entries.retain(|_, entry| entry.expires_at > now);
5754        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5755            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5756                return Err(failure(
5757                    NodeFailureCode::SpawnIdempotencyConflict,
5758                    "spawn idempotency key was reused across spawn kinds",
5759                ));
5760            };
5761            if existing != spec {
5762                return Err(failure(
5763                    NodeFailureCode::SpawnIdempotencyConflict,
5764                    "spawn idempotency key was reused with a different specification",
5765                ));
5766            }
5767            return Ok(Some(result.clone()));
5768        }
5769        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5770            return Err(failure(
5771                NodeFailureCode::SpawnIdempotencyCapacity,
5772                "spawn idempotency cache reached its bounded capacity",
5773            ));
5774        }
5775        Ok(None)
5776    }
5777
5778    fn remember_spawn_spec(
5779        &self,
5780        spec: SpawnSpec,
5781        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5782        accepted_at: Instant,
5783    ) {
5784        self
5785            .spawn_idempotency
5786            .lock()
5787            .unwrap_or_else(|poisoned| poisoned.into_inner())
5788            .entries
5789            .insert(
5790                spec.idempotency_key.clone(),
5791                SpawnIdempotencyEntry {
5792                    value: SpawnIdempotencyValue::Standard { spec, result },
5793                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5794                },
5795            );
5796    }
5797
5798    fn claim_harness_mcp_spawn(
5799        &self,
5800        _reservation_id: &HarnessMcpReservationId,
5801        _activation_digest: &HarnessMcpActivationDigest,
5802        spec: &SpawnSpec,
5803        now: Instant,
5804    ) -> Result<(), NodeFailure> {
5805        let mut cache = self.spawn_idempotency.lock()
5806            .unwrap_or_else(|poisoned| poisoned.into_inner());
5807        cache.entries.retain(|_, entry| entry.expires_at > now);
5808        if cache.entries.contains_key(&spec.idempotency_key) {
5809            return Err(failure(
5810                NodeFailureCode::SpawnIdempotencyConflict,
5811                "spawn idempotency key was reused across spawn kinds",
5812            ));
5813        }
5814        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5815            return Err(failure(
5816                NodeFailureCode::SpawnIdempotencyCapacity,
5817                "spawn idempotency cache reached its bounded capacity",
5818            ));
5819        }
5820        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5821            value: SpawnIdempotencyValue::HarnessMcp,
5822            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5823        });
5824        Ok(())
5825    }
5826
5827    fn replay_managed_spawn(
5828        &self,
5829        request: &ManagedWorktreeSpawnRequest,
5830        now: Instant,
5831    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5832        let mut cache = self.spawn_idempotency.lock()
5833            .unwrap_or_else(|poisoned| poisoned.into_inner());
5834        cache.entries.retain(|_, entry| entry.expires_at > now);
5835        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5836            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5837                return Err(failure(
5838                    NodeFailureCode::SpawnIdempotencyConflict,
5839                    "spawn idempotency key was reused across spawn kinds",
5840                ));
5841            };
5842            if existing != request {
5843                return Err(failure(
5844                    NodeFailureCode::SpawnIdempotencyConflict,
5845                    "spawn idempotency key was reused with a different managed specification",
5846                ));
5847            }
5848            return Ok(Some(result.clone()));
5849        }
5850        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5851            return Err(failure(
5852                NodeFailureCode::SpawnIdempotencyCapacity,
5853                "spawn idempotency cache reached its bounded capacity",
5854            ));
5855        }
5856        Ok(None)
5857    }
5858
5859    fn remember_managed_spawn(
5860        &self,
5861        request: ManagedWorktreeSpawnRequest,
5862        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5863        accepted_at: Instant,
5864    ) {
5865        self.spawn_idempotency.lock()
5866            .unwrap_or_else(|poisoned| poisoned.into_inner())
5867            .entries
5868            .insert(
5869                request.spawn_spec.idempotency_key.clone(),
5870                SpawnIdempotencyEntry {
5871                    value: SpawnIdempotencyValue::Managed { request, result },
5872                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5873                },
5874            );
5875    }
5876
5877    fn remember_managed_spawn_attempt(
5878        &self,
5879        durable_v2_key: Option<&SpawnIdempotencyKey>,
5880        request: ManagedWorktreeSpawnRequest,
5881        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5882        accepted_at: Instant,
5883    ) {
5884        if durable_v2_key.is_none() {
5885            self.remember_managed_spawn(request, result, accepted_at);
5886        }
5887    }
5888
5889    fn acquire_durable_managed_spawn_v2(
5890        &self,
5891        request: &ManagedWorktreeSpawnRequestV2,
5892        request_digest: String,
5893    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5894        if self.state_path.is_none() {
5895            return Err(failure(
5896                NodeFailureCode::BackendOperationFailed,
5897                "managed worktree V2 requires durable node state",
5898            ));
5899        }
5900        let _transaction = self.state_transaction
5901            .lock()
5902            .unwrap_or_else(|poisoned| poisoned.into_inner());
5903        let key = request.spawn_spec.idempotency_key.clone();
5904        let mut previous = None;
5905        {
5906            let mut replays = self.managed_spawn_replays
5907                .lock()
5908                .unwrap_or_else(|poisoned| poisoned.into_inner());
5909            if let Some(record) = replays.get_mut(&key) {
5910                if record.request_digest != request_digest {
5911                    return Err(failure(
5912                        NodeFailureCode::SpawnIdempotencyConflict,
5913                        "managed worktree V2 idempotency key was reused with a different request",
5914                    ));
5915                }
5916                match &record.state {
5917                    ManagedWorktreeSpawnReplayStateV10::Pending {
5918                        owner_incarnation_id,
5919                        lease_id: None,
5920                    } if owner_incarnation_id != &self.incarnation_id => {
5921                        previous = Some(record.clone());
5922                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5923                            owner_incarnation_id: self.incarnation_id,
5924                            lease_id: None,
5925                        };
5926                        record.updated_at_unix_ms = unix_time_ms();
5927                    }
5928                    ManagedWorktreeSpawnReplayStateV10::Pending {
5929                        lease_id: None,
5930                        ..
5931                    } => {
5932                        return Err(failure(
5933                            NodeFailureCode::BackendBusy,
5934                            "managed worktree V2 reservation is already active",
5935                        ));
5936                    }
5937                    ManagedWorktreeSpawnReplayStateV10::Pending {
5938                        lease_id: Some(_),
5939                        ..
5940                    } => {
5941                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5942                    }
5943                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5944                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5945                            receipt.clone(),
5946                        ));
5947                    }
5948                }
5949            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5950                return Err(failure(
5951                    NodeFailureCode::SpawnIdempotencyCapacity,
5952                    "durable managed worktree replay ledger reached its bounded capacity",
5953                ));
5954            } else {
5955                let now = unix_time_ms();
5956                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5957                    idempotency_key: key.clone(),
5958                    request_digest,
5959                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5960                    profile_id: request.worktree_profile_id.clone(),
5961                    expected_profile_revision: request.expected_profile_revision.clone(),
5962                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5963                        owner_incarnation_id: self.incarnation_id,
5964                        lease_id: None,
5965                    },
5966                    created_at_unix_ms: now,
5967                    updated_at_unix_ms: now,
5968                });
5969            }
5970        }
5971        if let Err(error) = self.persist_state_locked() {
5972            let mut replays = self.managed_spawn_replays
5973                .lock()
5974                .unwrap_or_else(|poisoned| poisoned.into_inner());
5975            match previous {
5976                Some(previous) => {
5977                    replays.insert(key, previous);
5978                }
5979                None => {
5980                    replays.remove(&key);
5981                }
5982            }
5983            return Err(persistence_failure(error));
5984        }
5985        Ok(DurableManagedSpawnReplayDecision::Reserved)
5986    }
5987
5988    fn link_durable_managed_spawn_v2_lease_locked(
5989        &self,
5990        key: &SpawnIdempotencyKey,
5991        lease_id: &ManagedWorktreeLeaseId,
5992    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5993        let mut replays = self.managed_spawn_replays
5994            .lock()
5995            .unwrap_or_else(|poisoned| poisoned.into_inner());
5996        let record = replays.get_mut(key).ok_or_else(|| {
5997            failure(
5998                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5999                "managed worktree V2 reservation disappeared before allocation",
6000            )
6001        })?;
6002        let previous = record.clone();
6003        match &mut record.state {
6004            ManagedWorktreeSpawnReplayStateV10::Pending {
6005                lease_id: current,
6006                ..
6007            }
6008                if current.is_none() =>
6009            {
6010                *current = Some(lease_id.clone());
6011                record.updated_at_unix_ms = unix_time_ms();
6012                Ok(previous)
6013            }
6014            _ => Err(failure(
6015                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6016                "managed worktree V2 reservation is not allocation-ready",
6017            )),
6018        }
6019    }
6020
6021    fn restore_durable_managed_spawn_v2_locked(
6022        &self,
6023        key: &SpawnIdempotencyKey,
6024        previous: ManagedWorktreeSpawnReplayRecordV10,
6025    ) {
6026        self.managed_spawn_replays
6027            .lock()
6028            .unwrap_or_else(|poisoned| poisoned.into_inner())
6029            .insert(key.clone(), previous);
6030    }
6031
6032    fn commit_durable_managed_spawn_v2(
6033        &self,
6034        request: &ManagedWorktreeSpawnRequestV2,
6035        receipt: ManagedWorktreeSpawnReceipt,
6036    ) -> Result<(), NodeFailure> {
6037        let _transaction = self.state_transaction
6038            .lock()
6039            .unwrap_or_else(|poisoned| poisoned.into_inner());
6040        let key = &request.spawn_spec.idempotency_key;
6041        let previous = {
6042            let mut replays = self.managed_spawn_replays
6043                .lock()
6044                .unwrap_or_else(|poisoned| poisoned.into_inner());
6045            let record = replays.get_mut(key).ok_or_else(|| {
6046                failure(
6047                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6048                    "managed worktree V2 reservation disappeared before commit",
6049                )
6050            })?;
6051            let previous = record.clone();
6052            match &record.state {
6053                ManagedWorktreeSpawnReplayStateV10::Pending {
6054                    lease_id: Some(lease_id),
6055                    ..
6056                } if lease_id == &receipt.lease.lease_id => {}
6057                _ => {
6058                    return Err(failure(
6059                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6060                        "managed worktree V2 reservation is not linked to the spawned lease",
6061                    ));
6062                }
6063            }
6064            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6065            record.updated_at_unix_ms = unix_time_ms();
6066            previous
6067        };
6068        if let Err(error) = self.persist_state_locked() {
6069            self.restore_durable_managed_spawn_v2_locked(key, previous);
6070            return Err(persistence_failure(error));
6071        }
6072        Ok(())
6073    }
6074
6075    fn remove_unallocated_durable_managed_spawn_v2(
6076        &self,
6077        request: &ManagedWorktreeSpawnRequestV2,
6078    ) -> Result<(), NodeFailure> {
6079        let _transaction = self.state_transaction
6080            .lock()
6081            .unwrap_or_else(|poisoned| poisoned.into_inner());
6082        let key = &request.spawn_spec.idempotency_key;
6083        let removed = {
6084            let mut replays = self.managed_spawn_replays
6085                .lock()
6086                .unwrap_or_else(|poisoned| poisoned.into_inner());
6087            match replays.get(key).map(|record| &record.state) {
6088                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6089                    lease_id: None,
6090                    ..
6091                }) => {
6092                    replays.remove(key)
6093                }
6094                _ => None,
6095            }
6096        };
6097        let Some(removed) = removed else {
6098            return Ok(());
6099        };
6100        if let Err(error) = self.persist_state_locked() {
6101            self.managed_spawn_replays
6102                .lock()
6103                .unwrap_or_else(|poisoned| poisoned.into_inner())
6104                .insert(key.clone(), removed);
6105            return Err(persistence_failure(error));
6106        }
6107        Ok(())
6108    }
6109
6110    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6111        if spec.target.node_id != self.node_id {
6112            return Err(failure(
6113                NodeFailureCode::SpawnTargetMismatch,
6114                "spawn target does not match this node",
6115            ));
6116        }
6117        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6118            failure(
6119                NodeFailureCode::UnknownSpawnProfile,
6120                "spawn profile is unavailable on this node",
6121            )
6122        })?;
6123        let resolved = spec.resolve(defaults).map_err(|error| match error {
6124            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6125                NodeFailureCode::SpawnProfileRevisionMismatch,
6126                "spawn profile revision does not match the loaded profile",
6127            ),
6128            _ => failure(
6129                NodeFailureCode::InvalidRequest,
6130                "spawn specification could not be resolved",
6131            ),
6132        })?;
6133        if resolved.target.worktree_id.is_some() {
6134            self.require_worktree_service(&resolved.target.workspace_id)?;
6135        }
6136        let environment_profile = self.resolve_environment_profile(&resolved)?;
6137        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6138        self.resolve_context(&resolved)?;
6139        Ok(resolved)
6140    }
6141
6142    async fn resolve_spawn_workspace(
6143        &self,
6144        resolved: &ResolvedSpawnSpec,
6145        deadline: Instant,
6146    ) -> Result<WorkspaceId, NodeFailure> {
6147        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6148            return Ok(resolved.target.workspace_id.clone());
6149        };
6150        if self.managed_worktrees.lock()
6151            .unwrap_or_else(|poisoned| poisoned.into_inner())
6152            .lease_for_workspace(worktree_id)
6153            .is_some()
6154        {
6155            return Err(failure(
6156                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6157                "managed worktree targets may only be spawned through their lease request",
6158            ));
6159        }
6160        self.require_worktree_service(&resolved.target.workspace_id)?;
6161        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6162        let selected_root = self.workspace_root(worktree_id)?;
6163        let remaining = spawn_deadline_remaining(deadline)?;
6164        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6165            .await
6166            .map_err(|_| {
6167                failure(
6168                    NodeFailureCode::SpawnDeadlineExceeded,
6169                    "Git worktree selection exceeded the spawn deadline",
6170                )
6171            })?
6172            .map_err(git_worktree_failure)?;
6173        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6174        Ok(worktree_id.clone())
6175    }
6176
6177    async fn spawn_from_spec(
6178        &self,
6179        spec: SpawnSpec,
6180    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6181        self.spawn_from_spec_with_proxy(spec, None).await
6182    }
6183
6184    async fn spawn_from_spec_with_proxy(
6185        &self,
6186        spec: SpawnSpec,
6187        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6188    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6189        let accepted_at = Instant::now();
6190        if harness_mcp.is_none() {
6191            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6192                return replayed;
6193            }
6194        }
6195        let resolved = self.resolve_spawn_spec(&spec)?;
6196        let environment_profile = self.resolve_environment_profile(&resolved)?;
6197        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6198        let context = self.resolve_context(&resolved)?;
6199        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6200        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6201        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6202        let spawn_workspace_id = match spawn_workspace_id {
6203            Ok(workspace_id) => workspace_id,
6204            Err(error) => {
6205                let result = Err(error);
6206                if harness_mcp.is_none() {
6207                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6208                }
6209                return result;
6210            }
6211        };
6212        if self.managed_worktrees.lock()
6213            .unwrap_or_else(|poisoned| poisoned.into_inner())
6214            .lease_for_workspace(&spawn_workspace_id)
6215            .is_some()
6216        {
6217            let result = Err(failure(
6218                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6219                "managed worktree targets may only be spawned through their lease request",
6220            ));
6221            if harness_mcp.is_none() {
6222                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6223            }
6224            return result;
6225        }
6226        let result = self
6227            .spawn_session_with_deadline(
6228                spawn_workspace_id,
6229                resolved.provider.clone(),
6230                resolved.mode,
6231                resolved.terminal_size,
6232                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6233                environment_profile.clone(),
6234                bundle.clone(),
6235                context.clone(),
6236                None,
6237                None,
6238                SpawnRecordPolicy::Always,
6239                Some(deadline),
6240                &required_capabilities,
6241                harness_mcp,
6242                resolved.approval_level,
6243                resolved.network_allowlist.clone(),
6244                resolved.browser_profile_id.clone(),
6245            )
6246            .await
6247            .map(|(session, _runtime_policy)| {
6248                resolved.receipt_with_materialization(
6249                    self.incarnation_id,
6250                    session,
6251                    environment_profile,
6252                    bundle,
6253                    context,
6254                )
6255            });
6256        if harness_mcp.is_none() {
6257            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6258        }
6259        result
6260    }
6261
6262    async fn spawn_from_spec_with_harness_mcp(
6263        &self,
6264        reservation_id: HarnessMcpReservationId,
6265        activation_digest: HarnessMcpActivationDigest,
6266        spec: SpawnSpec,
6267        deadline_unix_ms: u64,
6268    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6269        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6270            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6271        })?;
6272        if let Some(receipt) = registry
6273            .replay_spawn(&reservation_id, &activation_digest, &spec)
6274            .map_err(harness_mcp_failure)?
6275        {
6276            return Ok(receipt);
6277        }
6278        let resolved = self.resolve_spawn_spec(&spec)?;
6279        let prepared = registry
6280            .prepare_spawn(
6281                &reservation_id,
6282                &activation_digest,
6283                &spec,
6284                deadline_unix_ms,
6285                resolved.provider.clone(),
6286            )
6287            .map_err(harness_mcp_failure)?;
6288        self.claim_harness_mcp_spawn(
6289            &reservation_id,
6290            &activation_digest,
6291            &spec,
6292            Instant::now(),
6293        )?;
6294        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6295        let mut receipt = match spawn {
6296            Ok(receipt) => receipt,
6297            Err(error) => {
6298                let _ = registry.abort(&reservation_id, &activation_digest);
6299                return Err(error);
6300            }
6301        };
6302        let record_id = self
6303            .session_bindings
6304            .lock()
6305            .unwrap_or_else(|poisoned| poisoned.into_inner())
6306            .get(&receipt.session.session.instance_id)
6307            .and_then(|binding| binding.record_id.clone());
6308        let record_id = match record_id {
6309            Some(record_id) => record_id,
6310            None => {
6311                let rollback = self.remove_session(&receipt.session).await;
6312                let _ = registry.abort(&reservation_id, &activation_digest);
6313                if let Err(rollback_error) = rollback {
6314                    return Err(rollback_error);
6315                }
6316                return Err(failure(
6317                    NodeFailureCode::BindingMismatch,
6318                    "harness MCP spawn did not retain its exact managed record",
6319                ));
6320            }
6321        };
6322        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6323            reservation_id: reservation_id.clone(),
6324            activation_digest: activation_digest.clone(),
6325        });
6326        if let Err(error) = registry.mark_spawned(
6327            &prepared,
6328            receipt.session.clone(),
6329            record_id,
6330            receipt.clone(),
6331        ) {
6332            let rollback = self.remove_session(&receipt.session).await;
6333            let _ = registry.abort(&reservation_id, &activation_digest);
6334            if let Err(rollback_error) = rollback {
6335                return Err(rollback_error);
6336            }
6337            return Err(harness_mcp_failure(error));
6338        }
6339        Ok(receipt)
6340    }
6341
6342    async fn spawn_managed_worktree(
6343        &self,
6344        request: ManagedWorktreeSpawnRequest,
6345    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6346        self.spawn_managed_worktree_inner(request, None).await
6347    }
6348
6349    async fn spawn_managed_worktree_inner(
6350        &self,
6351        request: ManagedWorktreeSpawnRequest,
6352        durable_v2_key: Option<&SpawnIdempotencyKey>,
6353    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6354        let accepted_at = Instant::now();
6355        if durable_v2_key.is_none() {
6356            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6357                return replayed;
6358            }
6359        }
6360        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6361        if request.spawn_spec.target.worktree_id.is_some() {
6362            let result = Err(failure(
6363                NodeFailureCode::InvalidRequest,
6364                "managed worktree spawn must not select a caller-provided worktree",
6365            ));
6366            self.remember_managed_spawn_attempt(
6367                durable_v2_key,
6368                request,
6369                result.clone(),
6370                accepted_at,
6371            );
6372            return result;
6373        }
6374        let environment_profile = self.resolve_environment_profile(&resolved)?;
6375        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6376        let context = self.resolve_context(&resolved)?;
6377        let source_workspace_id = resolved.target.workspace_id.clone();
6378        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6379        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6380        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6381        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6382            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6383            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6384            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6385            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6386        };
6387        let runtime_policy = timeout(
6388            spawn_deadline_remaining(deadline)?,
6389            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6390        ).await.map_err(|_| failure(
6391            NodeFailureCode::SpawnDeadlineExceeded,
6392            "provider admission exceeded the managed spawn deadline",
6393        ))??;
6394        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6395            return Err(failure(
6396                NodeFailureCode::UnsupportedSpawnCapability,
6397                "provider runtime does not admit a required spawn capability",
6398            ));
6399        }
6400        self.ensure_binding_capacity()?;
6401        let source_root = self.workspace_root(&source_workspace_id)?;
6402        profile.validate_for_workspace(&source_root)
6403            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6404        let base_timeout_ms = u64::try_from(
6405            spawn_deadline_remaining(deadline)?.as_millis(),
6406        ).unwrap_or(u64::MAX).max(1);
6407        let base_commit = resolve_base_commit_with_timeout(
6408            &source_root,
6409            profile.base(),
6410            base_timeout_ms,
6411        ).await.map_err(|error| {
6412            if Instant::now() >= deadline || error.message.contains("timed out") {
6413                failure(
6414                    NodeFailureCode::SpawnDeadlineExceeded,
6415                    "managed worktree base resolution exceeded the spawn deadline",
6416                )
6417            } else {
6418                managed_git_worktree_failure(error)
6419            }
6420        })?;
6421
6422        let lease = {
6423            let _transaction = self.state_transaction.lock()
6424                .unwrap_or_else(|poisoned| poisoned.into_inner());
6425            let previous = self.managed_worktrees.lock()
6426                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6427            let lease = self.managed_worktrees.lock()
6428                .unwrap_or_else(|poisoned| poisoned.into_inner())
6429                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6430                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6431            let previous_replay = match durable_v2_key
6432                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6433                .transpose()
6434            {
6435                Ok(previous) => previous,
6436                Err(error) => {
6437                    *self.managed_worktrees.lock()
6438                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6439                    return Err(error);
6440                }
6441            };
6442            if let Err(error) = self.persist_state_locked() {
6443                *self.managed_worktrees.lock()
6444                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6445                if let (Some(key), Some(previous_replay)) =
6446                    (durable_v2_key, previous_replay)
6447                {
6448                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6449                }
6450                return Err(persistence_failure(error));
6451            }
6452            lease
6453        };
6454        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6455        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6456            self.managed_worktrees.lock()
6457                .unwrap_or_else(|poisoned| poisoned.into_inner())
6458                .remove_unmutated(&lease.lease_id);
6459            if self.persist_state().is_err() {
6460                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6461            }
6462            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6463            self.remember_managed_spawn_attempt(
6464                durable_v2_key,
6465                request,
6466                result.clone(),
6467                accepted_at,
6468            );
6469            return result;
6470        }
6471        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6472            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6473            Err(error) => {
6474                self.reconcile_failed_allocation(&lease, &source_root).await;
6475                let result = Err(error);
6476                self.remember_managed_spawn_attempt(
6477                    durable_v2_key,
6478                    request,
6479                    result.clone(),
6480                    accepted_at,
6481                );
6482                return result;
6483            }
6484        };
6485        let created = match create_git_worktree_with_timeout(
6486                &source_root,
6487                &lease.target_root,
6488                &lease.branch,
6489                Some(&base_commit),
6490                mutation_timeout_ms,
6491            ).await {
6492            Ok(created) => created,
6493            Err(error) => {
6494                self.reconcile_failed_allocation(&lease, &source_root).await;
6495                let result = Err(if Instant::now() >= deadline
6496                    || error.message.contains("timed out")
6497                    || error.message.contains("deadline")
6498                {
6499                    failure(
6500                        NodeFailureCode::SpawnDeadlineExceeded,
6501                        "managed worktree creation exceeded the spawn deadline",
6502                    )
6503                } else {
6504                    managed_git_worktree_failure(error)
6505                });
6506                self.remember_managed_spawn_attempt(
6507                    durable_v2_key,
6508                    request,
6509                    result.clone(),
6510                    accepted_at,
6511                );
6512                return result;
6513            }
6514        };
6515        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6516            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6517            let result = Err(failure(
6518                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6519                "created worktree identity did not match its durable lease",
6520            ));
6521            self.remember_managed_spawn_attempt(
6522                durable_v2_key,
6523                request,
6524                result.clone(),
6525                accepted_at,
6526            );
6527            return result;
6528        }
6529        {
6530            let mut registry = self.managed_worktrees.lock()
6531                .unwrap_or_else(|poisoned| poisoned.into_inner());
6532            let current = registry.get_mut(&lease.lease_id)
6533                .expect("allocated managed worktree lease remains present");
6534            current.expected_head = Some(created.head.clone());
6535            current.state = ManagedWorktreeLeaseState::Ready;
6536            current.updated_at_unix_ms = unix_time_ms();
6537        }
6538        if let Err(error) = self.persist_state() {
6539            self.mark_managed_recovery_required(
6540                &lease.lease_id,
6541                ManagedWorktreeCleanupFailure::Backend,
6542            );
6543            let result = Err(persistence_failure(error));
6544            self.remember_managed_spawn_attempt(
6545                durable_v2_key,
6546                request,
6547                result.clone(),
6548                accepted_at,
6549            );
6550            return result;
6551        }
6552        let ready = self.managed_worktrees.lock()
6553            .unwrap_or_else(|poisoned| poisoned.into_inner())
6554            .get(&lease.lease_id)
6555            .expect("ready managed worktree lease remains present")
6556            .snapshot();
6557        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6558        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6559            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6560            let result = Err(error);
6561            self.remember_managed_spawn_attempt(
6562                durable_v2_key,
6563                request,
6564                result.clone(),
6565                accepted_at,
6566            );
6567            return result;
6568        }
6569        let spawn = self.spawn_session_with_deadline(
6570            lease.workspace_id.clone(),
6571            resolved.provider.clone(),
6572            resolved.mode,
6573            resolved.terminal_size,
6574            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6575            environment_profile.clone(),
6576            bundle.clone(),
6577            context.clone(),
6578            Some(lease.lease_id.clone()),
6579            Some(runtime_policy),
6580            SpawnRecordPolicy::Always,
6581            Some(deadline),
6582            &required_capabilities,
6583            None,
6584            resolved.approval_level,
6585            resolved.network_allowlist.clone(),
6586            resolved.browser_profile_id.clone(),
6587        ).await;
6588        let (session, effective_runtime_policy) = match spawn {
6589            Ok(spawned) => spawned,
6590            Err(error) => {
6591                if self
6592                    .cleanup_managed_worktree(&lease.lease_id, true)
6593                    .await
6594                    .is_err()
6595                {
6596                    self.mark_managed_recovery_required(
6597                        &lease.lease_id,
6598                        ManagedWorktreeCleanupFailure::Backend,
6599                    );
6600                }
6601                let result = Err(error);
6602                self.remember_managed_spawn_attempt(
6603                    durable_v2_key,
6604                    request,
6605                    result.clone(),
6606                    accepted_at,
6607                );
6608                return result;
6609            }
6610        };
6611        let (record_id, raw_inventory_record_id) = {
6612            let mut bindings = self.session_bindings.lock()
6613                .unwrap_or_else(|poisoned| poisoned.into_inner());
6614            let binding = bindings.get_mut(&session.session.instance_id)
6615                .expect("successful spawn retains its session binding");
6616            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6617            managed_spawn_record_ownership(binding, effective_runtime_policy)
6618        };
6619        let bound = self.managed_worktrees.lock()
6620            .unwrap_or_else(|poisoned| poisoned.into_inner())
6621            .bind_session(
6622                &lease.lease_id,
6623                ManagedWorktreeSessionHolder {
6624                    incarnation_id: self.incarnation_id,
6625                    instance_id: session.session.instance_id,
6626                    generation: session.session.generation,
6627                },
6628                record_id,
6629                unix_time_ms(),
6630            )
6631            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6632        let snapshot = match bound {
6633            Ok(snapshot) => snapshot,
6634            Err(error) => {
6635                if raw_inventory_record_id
6636                    .as_ref()
6637                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6638                {
6639                    self.mark_managed_recovery_required(
6640                        &lease.lease_id,
6641                        ManagedWorktreeCleanupFailure::Backend,
6642                    );
6643                }
6644                let _ = self.remove_session(&session).await;
6645                self.mark_managed_recovery_required(
6646                    &lease.lease_id,
6647                    ManagedWorktreeCleanupFailure::Backend,
6648                );
6649                let result = Err(error);
6650                self.remember_managed_spawn_attempt(
6651                    durable_v2_key,
6652                    request,
6653                    result.clone(),
6654                    accepted_at,
6655                );
6656                return result;
6657            }
6658        };
6659        if let Err(error) = self.persist_state() {
6660            if raw_inventory_record_id
6661                .as_ref()
6662                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6663            {
6664                self.mark_managed_recovery_required(
6665                    &lease.lease_id,
6666                    ManagedWorktreeCleanupFailure::Backend,
6667                );
6668            }
6669            let _ = self.remove_session(&session).await;
6670            if self
6671                .cleanup_managed_worktree(&lease.lease_id, true)
6672                .await
6673                .is_err()
6674            {
6675                self.mark_managed_recovery_required(
6676                    &lease.lease_id,
6677                    ManagedWorktreeCleanupFailure::Backend,
6678                );
6679            }
6680            let result = Err(persistence_failure(error));
6681            self.remember_managed_spawn_attempt(
6682                durable_v2_key,
6683                request,
6684                result.clone(),
6685                accepted_at,
6686            );
6687            return result;
6688        }
6689        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6690        let receipt = managed_spawn_receipt(
6691            &resolved,
6692            self.incarnation_id,
6693            session,
6694            snapshot,
6695            environment_profile,
6696            bundle,
6697            context,
6698        );
6699        let result = Ok(receipt);
6700        self.remember_managed_spawn_attempt(
6701            durable_v2_key,
6702            request,
6703            result.clone(),
6704            accepted_at,
6705        );
6706        result
6707    }
6708
6709    async fn spawn_managed_worktree_v2(
6710        &self,
6711        request: ManagedWorktreeSpawnRequestV2,
6712    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6713        if request.spawn_spec.target.node_id != self.node_id {
6714            return Err(failure(
6715                NodeFailureCode::SpawnTargetMismatch,
6716                "spawn target does not match this node",
6717            ));
6718        }
6719        if request.spawn_spec.target.worktree_id.is_some() {
6720            return Err(failure(
6721                NodeFailureCode::InvalidRequest,
6722                "managed worktree spawn must not select a caller-provided worktree",
6723            ));
6724        }
6725        let request_digest = managed_spawn_request_digest_v2(&request)?;
6726        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6727        match replay {
6728            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6729            DurableManagedSpawnReplayDecision::PendingLinked => {
6730                return Err(failure(
6731                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6732                    "managed worktree V2 reservation has an incomplete durable allocation",
6733                ));
6734            }
6735            DurableManagedSpawnReplayDecision::Reserved => {}
6736        }
6737        let validated = (|| {
6738            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6739            let profile = self.managed_profile(
6740                &resolved.target.workspace_id,
6741                &request.worktree_profile_id,
6742            )?;
6743            if profile.revision() != &request.expected_profile_revision {
6744                return Err(failure(
6745                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6746                    "managed worktree profile revision does not match the loaded profile",
6747                ));
6748            }
6749            Ok(())
6750        })();
6751        match validated {
6752            Ok(()) => {}
6753            Err(error) => {
6754                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6755                return Err(error);
6756            }
6757        }
6758        let legacy = ManagedWorktreeSpawnRequest {
6759            spawn_spec: request.spawn_spec.clone(),
6760            worktree_profile_id: request.worktree_profile_id.clone(),
6761        };
6762        match self
6763            .spawn_managed_worktree_inner(
6764                legacy,
6765                Some(&request.spawn_spec.idempotency_key),
6766            )
6767            .await
6768        {
6769            Ok(receipt) => {
6770                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6771                Ok(receipt)
6772            }
6773            Err(error) => {
6774                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6775                Err(error)
6776            }
6777        }
6778    }
6779
6780    fn managed_profile(
6781        &self,
6782        workspace_id: &WorkspaceId,
6783        profile_id: &WorktreeProfileId,
6784    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6785        if self.worktree_service_modes.get(workspace_id).copied()
6786            != Some(WorktreeServiceMode::Managed)
6787        {
6788            return Err(failure(
6789                NodeFailureCode::UnsupportedSpawnCapability,
6790                "workspace does not enable managed worktrees",
6791            ));
6792        }
6793        self.managed_worktree_profiles.get(workspace_id)
6794            .and_then(|profiles| profiles.get(profile_id))
6795            .cloned()
6796            .ok_or_else(|| failure(
6797                NodeFailureCode::InvalidRequest,
6798                "managed worktree profile is unavailable for this workspace",
6799            ))
6800    }
6801
6802    async fn reconcile_failed_allocation(
6803        &self,
6804        lease: &ManagedWorktreeLeaseRecord,
6805        source_root: &str,
6806    ) {
6807        match list_git_worktrees(source_root).await {
6808            Ok(worktrees) => {
6809                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6810                    None => {
6811                        match std::fs::symlink_metadata(&lease.target_root) {
6812                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6813                                self.managed_worktrees.lock()
6814                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6815                                    .remove_unmutated(&lease.lease_id);
6816                                let _ = self.persist_state();
6817                            }
6818                            _ => self.mark_managed_recovery_required(
6819                                &lease.lease_id,
6820                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6821                            ),
6822                        }
6823                    }
6824                    Some(item) if exact_owned_worktree(
6825                        lease,
6826                        &item.path,
6827                        item.branch.as_deref(),
6828                        &item.head,
6829                    ) => {
6830                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6831                    }
6832                    Some(_) => self.mark_managed_recovery_required(
6833                        &lease.lease_id,
6834                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6835                    ),
6836                }
6837            }
6838            Err(_) => self.mark_managed_recovery_required(
6839                &lease.lease_id,
6840                ManagedWorktreeCleanupFailure::Backend,
6841            ),
6842        }
6843    }
6844
6845    async fn reconcile_managed_worktrees(&self) {
6846        let leases = self.managed_worktrees.lock()
6847            .unwrap_or_else(|poisoned| poisoned.into_inner())
6848            .active_records()
6849            .cloned()
6850            .collect::<Vec<_>>();
6851        for lease in leases {
6852            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6853                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6854                _ => {
6855                    self.mark_managed_recovery_required(
6856                        &lease.lease_id,
6857                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6858                    );
6859                    continue;
6860                }
6861            };
6862            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6863                self.mark_managed_recovery_required(
6864                    &lease.lease_id,
6865                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6866                );
6867                continue;
6868            }
6869            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6870                Ok(root) => root,
6871                Err(_) => {
6872                    self.mark_managed_recovery_required(
6873                        &lease.lease_id,
6874                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6875                    );
6876                    continue;
6877                }
6878            };
6879            let worktrees = match list_git_worktrees(&source_root).await {
6880                Ok(worktrees) => worktrees,
6881                Err(_) => {
6882                    self.mark_managed_recovery_required(
6883                        &lease.lease_id,
6884                        ManagedWorktreeCleanupFailure::Backend,
6885                    );
6886                    continue;
6887                }
6888            };
6889            let listed = worktrees.iter()
6890                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6891            let Some(listed) = listed else {
6892                match std::fs::symlink_metadata(&lease.target_root) {
6893                    Err(error) if error.kind() == io::ErrorKind::NotFound
6894                        && lease.state == ManagedWorktreeLeaseState::Allocating
6895                        && !lease.has_holders() => {
6896                            let removed = self.managed_worktrees.lock()
6897                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6898                                .tombstone(&lease.lease_id, unix_time_ms());
6899                            if self.persist_state().is_ok() && removed.is_some() {
6900                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6901                                    lease_id: lease.lease_id.clone(),
6902                                });
6903                            }
6904                        }
6905                    _ => self.mark_managed_recovery_required(
6906                        &lease.lease_id,
6907                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6908                    ),
6909                }
6910                continue;
6911            };
6912            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6913                &lease,
6914                &listed.path,
6915                listed.branch.as_deref(),
6916                &listed.head,
6917            ) {
6918                self.mark_managed_recovery_required(
6919                    &lease.lease_id,
6920                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6921                );
6922                continue;
6923            }
6924            if !self.workspaces.read()
6925                .unwrap_or_else(|poisoned| poisoned.into_inner())
6926                .contains_key(&lease.workspace_id)
6927            {
6928                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6929                    self.mark_managed_recovery_required(
6930                        &lease.lease_id,
6931                        ManagedWorktreeCleanupFailure::Backend,
6932                    );
6933                    continue;
6934                }
6935            }
6936            let snapshot = {
6937                let mut registry = self.managed_worktrees.lock()
6938                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6939                let current = registry.get_mut(&lease.lease_id)
6940                    .expect("reconciled managed lease remains present");
6941                current.expected_head = Some(listed.head.clone());
6942                current.cleanup_failure = None;
6943                current.state = if current.has_holders() {
6944                    ManagedWorktreeLeaseState::InUse
6945                } else if current.retention == ManagedWorktreeRetention::Retain {
6946                    ManagedWorktreeLeaseState::Retained
6947                } else {
6948                    ManagedWorktreeLeaseState::Ready
6949                };
6950                current.updated_at_unix_ms = unix_time_ms();
6951                current.snapshot()
6952            };
6953            if self.persist_state().is_err() {
6954                self.mark_managed_recovery_required(
6955                    &lease.lease_id,
6956                    ManagedWorktreeCleanupFailure::Backend,
6957                );
6958                continue;
6959            }
6960            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6961            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6962                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6963            }
6964        }
6965    }
6966
6967    /// Crash-recovery startup sweep: durably capture the pack for every
6968    /// `Live` managed record whose most recent session has not yet produced
6969    /// one (recovers a session whose `Exited{0}` event was observed but the
6970    /// Node crashed before the export finished). The reactive fast path for
6971    /// a single just-exited record does not go through this — see
6972    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6973    /// it can capture the exact `SessionAddress` before `publish_control`
6974    /// clears it, rather than re-deriving it from (by then likely already
6975    /// `Dormant`) record state the way a rescan naturally would.
6976    ///
6977    /// Returns as soon as every eligible record's export has been *spawned*,
6978    /// not completed — each one runs on its own detached task (see
6979    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6980    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6981    /// point this is called from `NodeServer::run()`, so nothing could ever
6982    /// drain the history round trip if this awaited it inline here either).
6983    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6984    /// is a silent skip for THIS record — an unusable source or a racing
6985    /// record is not surfaced to any caller.
6986    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6987        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6988            .session_records
6989            .lock()
6990            .unwrap_or_else(|poisoned| poisoned.into_inner())
6991            .records
6992            .values()
6993            .filter(|record| {
6994                record.state == ManagedSessionState::Live
6995                    && record.exported_context.is_none()
6996                    && record.provider_session.is_some()
6997            })
6998            .filter_map(|record| {
6999                record
7000                    .active_session
7001                    .clone()
7002                    .map(|session| (record.record_id.clone(), session))
7003            })
7004            .collect();
7005        for (record_id, session) in candidates {
7006            let shared = Arc::clone(self);
7007            tokio::spawn(async move {
7008                shared
7009                    .reconcile_context_pack_export_for_record(&record_id, session)
7010                    .await;
7011            });
7012        }
7013    }
7014
7015    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7016    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7017    /// round trip this performs (`discover_history`/`load_history`, via
7018    /// `export_context_pack_for_session_record`) can only ever settle once
7019    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7020    /// completion — and `tick()` is exclusively owned by, and only ever
7021    /// called from, that same drive-loop task. Awaiting this inline there
7022    /// would starve `tick()` and this call could never observe its own
7023    /// command complete.
7024    ///
7025    /// Because this now genuinely races the drive loop's own
7026    /// `publish_control` (which downgrades this exact record from `Live` to
7027    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7028    /// triggered this reconcile), the record legitimately may already be
7029    /// `Dormant` by the time this reads it, or may transition mid-flight —
7030    /// `allow_clean_detachment: true` on the downstream export call is what
7031    /// tolerates exactly that transition (and only that transition; see
7032    /// `session_record_export_target_matches`).
7033    async fn reconcile_context_pack_export_for_record(
7034        &self,
7035        record_id: &SessionRecordId,
7036        session: SessionAddress,
7037    ) {
7038        let Ok(record) = self.record(record_id) else {
7039            return;
7040        };
7041        if !matches!(
7042            record.state,
7043            ManagedSessionState::Live | ManagedSessionState::Dormant
7044        ) || record.exported_context.is_some()
7045            || record.provider_session.is_none()
7046        {
7047            return;
7048        }
7049        // Pack bytes are already durably committed inside this call, via
7050        // commit_context_pack_for_session_record's own durable
7051        // write-through (context_pack_store) — no separate step here.
7052        // Unclean exits (non-zero/forced/no loadable history) are already
7053        // rejected by context_pack_source_status_is_usable inside
7054        // materialize_context_pack: this simply returns Err and is skipped.
7055        let Ok(receipt) = self
7056            .export_context_pack_for_session_record(&record.record_id, &session, true)
7057            .await
7058        else {
7059            return;
7060        };
7061        self.set_exported_context(&record.record_id, receipt);
7062    }
7063
7064    /// Records the record's most recent clean-exit pack. No-op (not an
7065    /// error) if the record disappeared, or if it already carries an
7066    /// `exported_context` — first-write-wins, matching the harness-side
7067    /// idempotent reconciliation that consumes this field.
7068    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7069        let transaction = self
7070            .state_transaction
7071            .lock()
7072            .unwrap_or_else(|poisoned| poisoned.into_inner());
7073        let (previous, updated) = {
7074            let mut records = self
7075                .session_records
7076                .lock()
7077                .unwrap_or_else(|poisoned| poisoned.into_inner());
7078            let Some(record) = records.records.get_mut(record_id) else {
7079                return;
7080            };
7081            if record.exported_context.is_some() {
7082                return;
7083            }
7084            let previous = record.clone();
7085            record.exported_context = Some(receipt);
7086            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7087            (previous, record.clone())
7088        };
7089        if self.persist_state_locked().is_err() {
7090            self.session_records
7091                .lock()
7092                .unwrap_or_else(|poisoned| poisoned.into_inner())
7093                .records
7094                .insert(record_id.clone(), previous);
7095            return;
7096        }
7097        drop(transaction);
7098        self.publish_record(updated);
7099    }
7100
7101    /// Resolves the managed record and its exact `SessionAddress` currently
7102    /// bound to `instance_id` at exactly `generation`, used by the reactive
7103    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7104    /// record without a full rescan. Mirrors the lookup already inlined in
7105    /// `publish_control`. Captured synchronously, before `publish_control`
7106    /// for the same event clears the record's own `active_session` — the
7107    /// caller passes the resolved address through to a detached
7108    /// reconciliation task rather than re-deriving it from the record later,
7109    /// after that race has necessarily already happened.
7110    fn managed_record_export_target_for_instance(
7111        &self,
7112        instance_id: AgentInstanceId,
7113        generation: SessionGeneration,
7114    ) -> Option<(SessionRecordId, SessionAddress)> {
7115        let bindings = self
7116            .session_bindings
7117            .lock()
7118            .unwrap_or_else(|poisoned| poisoned.into_inner());
7119        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7120        let record_id = binding.record_id.clone()?;
7121        Some((
7122            record_id,
7123            SessionAddress {
7124                workspace_id: binding.workspace_id.clone(),
7125                session: SessionKey { instance_id, generation },
7126            },
7127        ))
7128    }
7129
7130    fn mark_managed_recovery_required(
7131        &self,
7132        lease_id: &ManagedWorktreeLeaseId,
7133        failure_kind: ManagedWorktreeCleanupFailure,
7134    ) {
7135        let snapshot = {
7136            let mut registry = self.managed_worktrees.lock()
7137                .unwrap_or_else(|poisoned| poisoned.into_inner());
7138            let Some(lease) = registry.get_mut(lease_id) else { return };
7139            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7140            lease.cleanup_failure = Some(failure_kind);
7141            lease.updated_at_unix_ms = unix_time_ms();
7142            lease.snapshot()
7143        };
7144        let _ = self.persist_state();
7145        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7146    }
7147
7148    fn set_persistence_error(&self, error: Option<String>) {
7149        *self
7150            .persistence_error
7151            .write()
7152            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7153            .map(|message| session_registry::sanitized_persistence_summary(&message));
7154    }
7155
7156    fn persist_state(&self) -> io::Result<()> {
7157        let _transaction = self
7158            .state_transaction
7159            .lock()
7160            .unwrap_or_else(|poisoned| poisoned.into_inner());
7161        self.persist_state_locked()
7162    }
7163
7164    fn persist_state_locked(&self) -> io::Result<()> {
7165        let workspaces = self
7166            .workspaces
7167            .read()
7168            .unwrap_or_else(|poisoned| poisoned.into_inner())
7169            .clone();
7170        let records = self
7171            .session_records
7172            .lock()
7173            .unwrap_or_else(|poisoned| poisoned.into_inner())
7174            .records
7175            .values()
7176            .cloned()
7177            .collect::<Vec<_>>();
7178        let mut managed = self.managed_worktrees
7179            .lock()
7180            .unwrap_or_else(|poisoned| poisoned.into_inner());
7181        managed.reattach_record_holders(
7182            &managed_worktree_record_holders(&records),
7183            unix_time_ms(),
7184        );
7185        let materializations = self
7186            .materializations
7187            .lock()
7188            .unwrap_or_else(|poisoned| poisoned.into_inner())
7189            .values()
7190            .cloned()
7191            .collect::<Vec<_>>();
7192        let managed_spawn_replays = self.managed_spawn_replays
7193            .lock()
7194            .unwrap_or_else(|poisoned| poisoned.into_inner())
7195            .values()
7196            .cloned()
7197            .collect::<Vec<_>>();
7198        let result = session_registry::save_v10(
7199            self.state_path.as_deref(),
7200            &self.node_id,
7201            &workspaces,
7202            &records,
7203            &managed.records(),
7204            &managed.tombstones(),
7205            &materializations,
7206            &managed_spawn_replays,
7207        );
7208        match &result {
7209            Ok(warning) => self.set_persistence_error(warning.clone()),
7210            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7211        }
7212        result.map(|_| ())
7213    }
7214
7215    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7216        let _mutation_guard = self.mutation_gate.lock().await;
7217        self.begin_shutdown_locked().await
7218    }
7219
7220    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7221        if self.shutdown.swap(true, Ordering::AcqRel) {
7222            return Ok(());
7223        }
7224        SessionLifecycleHook::on_shutdown(self);
7225        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7226        let mut first_error = None;
7227        for session in &self.handle.snapshot().sessions {
7228            let remaining = deadline.saturating_duration_since(Instant::now());
7229            if remaining.is_zero() {
7230                first_error.get_or_insert_with(|| {
7231                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7232                });
7233                break;
7234            }
7235            if let Err(error) = self
7236                .dispatch_bounded(
7237                    ControlCommand::Stop {
7238                        instance_id: session.instance_id,
7239                        force: true,
7240                    },
7241                    remaining,
7242                )
7243                .await
7244            {
7245                first_error.get_or_insert(error);
7246            }
7247        }
7248        self.shutdown_notify.notify_waiters();
7249        match first_error {
7250            Some(error) => Err(error),
7251            None => Ok(()),
7252        }
7253    }
7254
7255    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7256        let bindings = self
7257            .session_bindings
7258            .lock()
7259            .unwrap_or_else(|poisoned| poisoned.into_inner());
7260        if bindings.len() >= CONTROL_SESSIONS_MAX {
7261            Err(failure(
7262                NodeFailureCode::BackendBusy,
7263                "node session-binding capacity is full; remove an existing session first",
7264            ))
7265        } else {
7266            Ok(())
7267        }
7268    }
7269
7270    /// One-owner workspace/env collision guard: a live session on
7271    /// `workspace_id` with a different `environment_profile` (option
7272    /// equality on the resolved receipt, including Some vs None) blocks
7273    /// another spawn on that same cwd. Same profile may still share the
7274    /// workspace; distinct profiles need distinct workspace ids (managed
7275    /// worktree).
7276    fn ensure_workspace_environment_profile_compatible(
7277        &self,
7278        workspace_id: &WorkspaceId,
7279        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7280    ) -> Result<(), NodeFailure> {
7281        let bindings = self
7282            .session_bindings
7283            .lock()
7284            .unwrap_or_else(|poisoned| poisoned.into_inner());
7285        for binding in bindings.values() {
7286            if &binding.workspace_id != workspace_id {
7287                continue;
7288            }
7289            if binding.environment_profile.as_ref() != environment_profile {
7290                return Err(failure(
7291                    NodeFailureCode::WorkspaceBusy,
7292                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7293                ));
7294            }
7295        }
7296        Ok(())
7297    }
7298
7299    #[cfg(test)]
7300    fn bind_session(
7301        &self,
7302        instance_id: AgentInstanceId,
7303        workspace_id: WorkspaceId,
7304        generation: SessionGeneration,
7305    ) {
7306        self.bind_session_with_policy(
7307            &SessionAddress {
7308                workspace_id,
7309                session: SessionKey { instance_id, generation },
7310            },
7311            ProviderRuntimePolicy::raw_pty(),
7312            None,
7313        );
7314    }
7315
7316    #[cfg(test)]
7317    fn bind_session_with_policy(
7318        &self,
7319        address: &SessionAddress,
7320        runtime_policy: ProviderRuntimePolicy,
7321        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7322    ) {
7323        self.bind_session_with_materialization(
7324            address,
7325            runtime_policy,
7326            environment_profile,
7327            None,
7328            None,
7329            None,
7330        );
7331    }
7332
7333    fn bind_session_with_materialization(
7334        &self,
7335        address: &SessionAddress,
7336        runtime_policy: ProviderRuntimePolicy,
7337        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7338        bundle: Option<ResolvedBundleReceipt>,
7339        context: Option<ResolvedContextPackReceipt>,
7340        materialization_id: Option<MaterializationId>,
7341    ) {
7342        let mut bindings = self
7343            .session_bindings
7344            .lock()
7345            .unwrap_or_else(|poisoned| poisoned.into_inner());
7346        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7347        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7348        bindings.insert(address.session.instance_id, SessionBinding {
7349            workspace_id: address.workspace_id.clone(),
7350            generation: address.session.generation,
7351            runtime_policy,
7352            pending_resume: None,
7353            record_id: None,
7354            managed_worktree_lease_id: None,
7355            environment_profile,
7356            bundle,
7357            context,
7358            materialization_id,
7359        });
7360    }
7361
7362    #[cfg(test)]
7363    fn bind_managed_session(
7364        &self,
7365        address: &SessionAddress,
7366        record_id: SessionRecordId,
7367        runtime_policy: ProviderRuntimePolicy,
7368        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7369    ) {
7370        self.bind_managed_session_with_materialization(
7371            address,
7372            record_id,
7373            runtime_policy,
7374            environment_profile,
7375            None,
7376            None,
7377            None,
7378        );
7379    }
7380
7381    fn bind_managed_session_with_materialization(
7382        &self,
7383        address: &SessionAddress,
7384        record_id: SessionRecordId,
7385        runtime_policy: ProviderRuntimePolicy,
7386        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7387        bundle: Option<ResolvedBundleReceipt>,
7388        context: Option<ResolvedContextPackReceipt>,
7389        materialization_id: Option<MaterializationId>,
7390    ) {
7391        let mut bindings = self
7392            .session_bindings
7393            .lock()
7394            .unwrap_or_else(|poisoned| poisoned.into_inner());
7395        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7396        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7397        bindings.insert(
7398            address.session.instance_id,
7399            SessionBinding {
7400                workspace_id: address.workspace_id.clone(),
7401                generation: address.session.generation,
7402                runtime_policy,
7403                pending_resume: None,
7404                record_id: Some(record_id),
7405                managed_worktree_lease_id: None,
7406                environment_profile,
7407                bundle,
7408                context,
7409                materialization_id,
7410            },
7411        );
7412    }
7413
7414    #[cfg(test)]
7415    fn bind_spawn_session(
7416        &self,
7417        address: &SessionAddress,
7418        provider: AgentId,
7419        mode: SessionMode,
7420        runtime_policy: ProviderRuntimePolicy,
7421        record_policy: SpawnRecordPolicy,
7422        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7423    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7424        self.bind_spawn_session_with_materialization(
7425            address,
7426            provider,
7427            mode,
7428            runtime_policy,
7429            record_policy,
7430            environment_profile,
7431            None,
7432            None,
7433            None,
7434        )
7435    }
7436
7437    fn bind_spawn_session_with_materialization(
7438        &self,
7439        address: &SessionAddress,
7440        provider: AgentId,
7441        mode: SessionMode,
7442        runtime_policy: ProviderRuntimePolicy,
7443        record_policy: SpawnRecordPolicy,
7444        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7445        bundle: Option<ResolvedBundleReceipt>,
7446        context: Option<ResolvedContextPackReceipt>,
7447        materialization_id: Option<MaterializationId>,
7448    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7449        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7450            && !runtime_policy.provider_session_identity
7451        {
7452            self.bind_session_with_materialization(
7453                address,
7454                runtime_policy,
7455                environment_profile,
7456                bundle,
7457                context,
7458                materialization_id,
7459            );
7460            return Ok(None);
7461        }
7462        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7463        let record = self.new_record(
7464            address,
7465            provider,
7466            mode,
7467            active_record_state(runtime_policy.provider_session_identity, false),
7468            None,
7469            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7470            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7471            record_owns_lifecycle.then(|| context.clone()).flatten(),
7472        )?;
7473        let record_id = record.record_id.clone();
7474        let transaction = self
7475            .state_transaction
7476            .lock()
7477            .unwrap_or_else(|poisoned| poisoned.into_inner());
7478        self.insert_record(record.clone())?;
7479        let previous_materialization = if record_owns_lifecycle {
7480            if let Some(materialization_id) = materialization_id.as_ref() {
7481                let mut materializations = self
7482                    .materializations
7483                    .lock()
7484                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7485                let Some(ownership) = materializations.get_mut(materialization_id) else {
7486                    drop(materializations);
7487                    self.remove_record_memory(&record_id);
7488                    return Err(failure(
7489                        NodeFailureCode::BackendOperationFailed,
7490                        "session environment ownership disappeared before binding",
7491                    ));
7492                };
7493                let previous = ownership.clone();
7494                if ownership
7495                    .transfer_to_record(record_id.clone(), unix_time_ms())
7496                    .is_err()
7497                {
7498                    drop(materializations);
7499                    self.remove_record_memory(&record_id);
7500                    return Err(failure(
7501                        NodeFailureCode::BackendOperationFailed,
7502                        "session environment ownership transfer failed",
7503                    ));
7504                }
7505                Some(previous)
7506            } else {
7507                None
7508            }
7509        } else {
7510            None
7511        };
7512        self.bind_managed_session_with_materialization(
7513            address,
7514            record_id.clone(),
7515            runtime_policy,
7516            environment_profile,
7517            bundle,
7518            context,
7519            materialization_id.clone(),
7520        );
7521        if let Err(error) = self.persist_state_locked() {
7522            self.remove_binding(address);
7523            self.remove_record_memory(&record_id);
7524            if let (Some(materialization_id), Some(previous)) =
7525                (materialization_id.as_ref(), previous_materialization)
7526            {
7527                self.materializations
7528                    .lock()
7529                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7530                    .insert(materialization_id.clone(), previous);
7531            }
7532            return Err(persistence_failure(error));
7533        }
7534        drop(transaction);
7535        self.publish_record(record);
7536        Ok(Some(record_id))
7537    }
7538
7539    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7540        for _ in 0..8 {
7541            let bytes = random_nonce().map_err(|error| {
7542                failure(
7543                    NodeFailureCode::BackendOperationFailed,
7544                    &format!("session record identity generation failed: {error}"),
7545                )
7546            })?;
7547            let mut value = String::with_capacity(27);
7548            value.push_str("sr-");
7549            for byte in &bytes[..12] {
7550                use std::fmt::Write as _;
7551                let _ = write!(&mut value, "{byte:02x}");
7552            }
7553            let record_id = SessionRecordId::new(value).map_err(|error| {
7554                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7555            })?;
7556            let records = self
7557                .session_records
7558                .lock()
7559                .unwrap_or_else(|poisoned| poisoned.into_inner());
7560            if !records.records.contains_key(&record_id) {
7561                return Ok(record_id);
7562            }
7563        }
7564        Err(failure(
7565            NodeFailureCode::BackendBusy,
7566            "could not allocate a unique session record ID",
7567        ))
7568    }
7569
7570    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7571        for _ in 0..8 {
7572            let bytes = random_nonce().map_err(|error| {
7573                failure(
7574                    NodeFailureCode::BackendOperationFailed,
7575                    &format!("task identity generation failed: {error}"),
7576                )
7577            })?;
7578            let mut nonce = [0_u8; 12];
7579            nonce.copy_from_slice(&bytes[..12]);
7580            let task_id = TaskId::from_nonce(nonce);
7581            let records = self
7582                .session_records
7583                .lock()
7584                .unwrap_or_else(|poisoned| poisoned.into_inner());
7585            if !records.records.values().any(|record| {
7586                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7587                    == Some(&task_id)
7588            }) {
7589                return Ok(task_id);
7590            }
7591        }
7592        Err(failure(
7593            NodeFailureCode::BackendBusy,
7594            "could not allocate a unique task ID",
7595        ))
7596    }
7597
7598    fn default_record_name(&self, provider: &AgentId) -> String {
7599        let records = self
7600            .session_records
7601            .lock()
7602            .unwrap_or_else(|poisoned| poisoned.into_inner());
7603        let ordinal = records
7604            .records
7605            .values()
7606            .filter(|record| &record.provider == provider)
7607            .count()
7608            .saturating_add(1);
7609        format!("{} #{ordinal}", provider.as_str())
7610    }
7611
7612    fn new_record(
7613        &self,
7614        address: &SessionAddress,
7615        provider: AgentId,
7616        mode: SessionMode,
7617        state: ManagedSessionState,
7618        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7619        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7620        bundle: Option<ResolvedBundleReceipt>,
7621        context: Option<ResolvedContextPackReceipt>,
7622    ) -> Result<ManagedSessionRecord, NodeFailure> {
7623        let canonical_root = self.workspace_root(&address.workspace_id)?;
7624        let now = unix_time_ms();
7625        Ok(ManagedSessionRecord {
7626            record_id: self.allocate_record_id()?,
7627            display_name: self.default_record_name(&provider),
7628            provider,
7629            mode,
7630            state,
7631            workspace_id: address.workspace_id.clone(),
7632            canonical_root: opaque_windows_path(canonical_root),
7633            provider_session,
7634            active_session: Some(address.clone()),
7635            environment_profile,
7636            bundle,
7637            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7638            context,
7639            exported_context: None,
7640            task_binding: None,
7641            created_at_unix_ms: now,
7642            updated_at_unix_ms: now,
7643            last_error: None,
7644        })
7645    }
7646
7647    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7648        let mut records = self
7649            .session_records
7650            .lock()
7651            .unwrap_or_else(|poisoned| poisoned.into_inner());
7652        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7653            return Err(failure(
7654                NodeFailureCode::BackendBusy,
7655                "managed session record capacity is full; forget an inactive record first",
7656            ));
7657        }
7658        if let Some(identity) = record.provider_session.as_ref() {
7659            if records.records.values().any(|candidate| {
7660                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7661                    session_registry::same_provider_session(
7662                        &candidate.provider,
7663                        candidate_identity,
7664                        &record.provider,
7665                        identity,
7666                    )
7667                })
7668            }) {
7669                return Err(failure(
7670                    NodeFailureCode::SessionRecordConflict,
7671                    "provider session is already represented by another managed record",
7672                ));
7673            }
7674        }
7675        match records.records.entry(record.record_id.clone()) {
7676            std::collections::btree_map::Entry::Vacant(entry) => {
7677                entry.insert(record);
7678            }
7679            std::collections::btree_map::Entry::Occupied(_) => {
7680                return Err(failure(
7681                    NodeFailureCode::SessionRecordConflict,
7682                    "session record ID collision",
7683                ));
7684            }
7685        }
7686        Ok(())
7687    }
7688
7689    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7690        self.session_records
7691            .lock()
7692            .unwrap_or_else(|poisoned| poisoned.into_inner())
7693            .records
7694            .get(record_id)
7695            .cloned()
7696            .ok_or_else(|| {
7697                failure(
7698                    NodeFailureCode::UnknownSessionRecord,
7699                    "managed session record does not exist",
7700                )
7701            })
7702    }
7703
7704    fn revalidate_session_record_preview(
7705        &self,
7706        expected: &ManagedSessionRecord,
7707        identity: &ProviderSessionIdentity,
7708    ) -> Result<(), NodeFailure> {
7709        let current = self.record(&expected.record_id)?;
7710        let current_root = self.workspace_root(&current.workspace_id)?;
7711        if current.provider != expected.provider
7712            || current.workspace_id != expected.workspace_id
7713            || current.canonical_root != expected.canonical_root
7714            || current.provider_session.as_ref() != Some(identity)
7715            || !platform::roots_equal(
7716                &current_root,
7717                windows_path_text(&current.canonical_root),
7718            )
7719        {
7720            return Err(failure(
7721                NodeFailureCode::SessionRecordConflict,
7722                "managed session changed while its preview was loading",
7723            ));
7724        }
7725        Ok(())
7726    }
7727
7728    fn revalidate_session_record_context_export(
7729        &self,
7730        expected: &ManagedSessionRecord,
7731        identity: Option<&ProviderSessionIdentity>,
7732        session: &SessionAddress,
7733        allow_clean_detachment: bool,
7734    ) -> Result<(), NodeFailure> {
7735        let current = self.record(&expected.record_id)?;
7736        let current_root = self.workspace_root(&current.workspace_id)?;
7737        let snapshot = self.internal_session_snapshot(session)?;
7738        let binding = self
7739            .session_bindings
7740            .lock()
7741            .unwrap_or_else(|poisoned| poisoned.into_inner())
7742            .get(&session.session.instance_id)
7743            .cloned()
7744            .ok_or_else(|| {
7745                failure(
7746                    NodeFailureCode::SessionRecordConflict,
7747                    "managed session runtime binding disappeared during context export",
7748                )
7749            })?;
7750        if !session_record_context_export_binding_is_exact(
7751            expected,
7752            &current,
7753            identity,
7754            session,
7755            &binding,
7756            &snapshot.agent_id,
7757            &current_root,
7758            allow_clean_detachment,
7759        ) || !session_record_context_export_source_is_usable(
7760            &current,
7761            &snapshot.status,
7762            allow_clean_detachment,
7763        ) {
7764            return Err(failure(
7765                NodeFailureCode::SessionRecordConflict,
7766                "managed session binding changed during context export",
7767            ));
7768        }
7769        Ok(())
7770    }
7771
7772    fn revalidate_loaded_history(
7773        &self,
7774        session: &SessionAddress,
7775        candidate_id: &str,
7776        loaded: &HistorySessionRecord,
7777    ) -> Result<(), NodeFailure> {
7778        let snapshot = self.internal_session_snapshot(session)?;
7779        if snapshot.history.pending.is_some()
7780            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7781            || snapshot.history.loaded.as_ref() != Some(loaded)
7782        {
7783            return Err(failure(
7784                NodeFailureCode::SessionRecordConflict,
7785                "managed session history changed during context export",
7786            ));
7787        }
7788        Ok(())
7789    }
7790
7791    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7792        self.session_records
7793            .lock()
7794            .unwrap_or_else(|poisoned| poisoned.into_inner())
7795            .records
7796            .remove(record_id)
7797    }
7798
7799    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7800        if self.destroy_record_materialization(record_id)? {
7801            self.publish(NodeEvent::SessionRecordRemoved {
7802                record_id: record_id.clone(),
7803            });
7804            return Ok(());
7805        }
7806        let transaction = self
7807            .state_transaction
7808            .lock()
7809            .unwrap_or_else(|poisoned| poisoned.into_inner());
7810        let Some(removed) = self.remove_record_memory(record_id) else {
7811            return Ok(());
7812        };
7813        if let Err(error) = self.persist_state_locked() {
7814            let _ = self.insert_record(removed);
7815            return Err(persistence_failure(error));
7816        }
7817        drop(transaction);
7818        self.publish(NodeEvent::SessionRecordRemoved {
7819            record_id: record_id.clone(),
7820        });
7821        Ok(())
7822    }
7823
7824    fn publish_record(&self, record: ManagedSessionRecord) {
7825        self.publish(NodeEvent::SessionRecordUpserted { record });
7826    }
7827
7828    fn mark_record_error(
7829        &self,
7830        record_id: &SessionRecordId,
7831        message: &str,
7832    ) -> Result<(), NodeFailure> {
7833        let transaction = self
7834            .state_transaction
7835            .lock()
7836            .unwrap_or_else(|poisoned| poisoned.into_inner());
7837        let previous = {
7838            let mut records = self
7839                .session_records
7840                .lock()
7841                .unwrap_or_else(|poisoned| poisoned.into_inner());
7842            let Some(record) = records.records.get_mut(record_id) else {
7843                return Ok(());
7844            };
7845            let previous = record.clone();
7846            record.state = ManagedSessionState::Unavailable;
7847            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7848            record.updated_at_unix_ms = unix_time_ms();
7849            previous
7850        };
7851        if let Err(error) = self.persist_state_locked() {
7852            self.session_records
7853                .lock()
7854                .unwrap_or_else(|poisoned| poisoned.into_inner())
7855                .records
7856                .insert(record_id.clone(), previous);
7857            return Err(persistence_failure(error));
7858        }
7859        let record = self.record(record_id)?;
7860        drop(transaction);
7861        self.publish_record(record);
7862        Ok(())
7863    }
7864
7865    fn rename_session_record(
7866        &self,
7867        record_id: &SessionRecordId,
7868        display_name: String,
7869    ) -> Result<ManagedSessionRecord, NodeFailure> {
7870        validate_display_name(&display_name)
7871            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7872        let _transaction = self
7873            .state_transaction
7874            .lock()
7875            .unwrap_or_else(|poisoned| poisoned.into_inner());
7876        let previous = {
7877            let mut records = self
7878                .session_records
7879                .lock()
7880                .unwrap_or_else(|poisoned| poisoned.into_inner());
7881            let record = records.records.get_mut(record_id).ok_or_else(|| {
7882                failure(
7883                    NodeFailureCode::UnknownSessionRecord,
7884                    "managed session record does not exist",
7885                )
7886            })?;
7887            let previous = record.clone();
7888            record.display_name = display_name;
7889            record.updated_at_unix_ms = unix_time_ms();
7890            previous
7891        };
7892        if let Err(error) = self.persist_state_locked() {
7893            self.session_records
7894                .lock()
7895                .unwrap_or_else(|poisoned| poisoned.into_inner())
7896                .records
7897                .insert(record_id.clone(), previous);
7898            return Err(persistence_failure(error));
7899        }
7900        let updated = self.record(record_id)?;
7901        self.publish_record(updated.clone());
7902        Ok(updated)
7903    }
7904
7905    fn set_session_task(
7906        &self,
7907        record_id: &SessionRecordId,
7908        expected_revision: u64,
7909        target: SessionTaskTargetV1,
7910    ) -> Result<ManagedSessionRecord, NodeFailure> {
7911        let transaction = self
7912            .state_transaction
7913            .lock()
7914            .unwrap_or_else(|poisoned| poisoned.into_inner());
7915        let current = self.record(record_id)?;
7916        let current_task_id = current
7917            .task_binding
7918            .as_ref()
7919            .and_then(|binding| binding.task_id.as_ref());
7920        let current_revision = current
7921            .task_binding
7922            .as_ref()
7923            .map_or(0, |binding| binding.revision);
7924        if expected_revision != current_revision {
7925            return Err(failure(
7926                NodeFailureCode::SessionRecordConflict,
7927                "managed session task binding revision changed",
7928            ));
7929        }
7930        let exact = match &target {
7931            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7932            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7933            SessionTaskTargetV1::New => false,
7934        };
7935        if exact {
7936            return Ok(current);
7937        }
7938        let revision = current_revision.checked_add(1).ok_or_else(|| {
7939            failure(
7940                NodeFailureCode::SessionRecordConflict,
7941                "managed session task binding revision is exhausted",
7942            )
7943        })?;
7944        let task_id = match target {
7945            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7946            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7947            SessionTaskTargetV1::Clear => None,
7948        };
7949        let changed_at_unix_ms = unix_time_ms()
7950            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7951                failure(
7952                    NodeFailureCode::SessionRecordConflict,
7953                    "managed session record timestamp is exhausted",
7954                )
7955            })?);
7956        let updated = {
7957            let mut records = self
7958                .session_records
7959                .lock()
7960                .unwrap_or_else(|poisoned| poisoned.into_inner());
7961            let record = records.records.get_mut(record_id).ok_or_else(|| {
7962                failure(
7963                    NodeFailureCode::UnknownSessionRecord,
7964                    "managed session record does not exist",
7965                )
7966            })?;
7967            record.task_binding = Some(SessionTaskBindingV1 {
7968                revision,
7969                task_id,
7970                changed_at_unix_ms,
7971            });
7972            record.updated_at_unix_ms = changed_at_unix_ms;
7973            record.clone()
7974        };
7975        if let Err(error) = self.persist_state_locked() {
7976            self.session_records
7977                .lock()
7978                .unwrap_or_else(|poisoned| poisoned.into_inner())
7979                .records
7980                .insert(record_id.clone(), current);
7981            return Err(persistence_failure(error));
7982        }
7983        drop(transaction);
7984        self.publish_record(updated.clone());
7985        Ok(updated)
7986    }
7987
7988    fn index_provider_session(
7989        &self,
7990        workspace_id: WorkspaceId,
7991        provider: AgentId,
7992        identity: ProviderSessionIdentity,
7993        display_name: String,
7994    ) -> Result<ManagedSessionRecord, NodeFailure> {
7995        self.index_provider_session_with_policy(
7996            workspace_id,
7997            provider,
7998            identity,
7999            display_name,
8000            false,
8001        )
8002    }
8003
8004    fn index_provider_session_with_policy(
8005        &self,
8006        workspace_id: WorkspaceId,
8007        provider: AgentId,
8008        identity: ProviderSessionIdentity,
8009        display_name: String,
8010        allow_validated_transcript_path: bool,
8011    ) -> Result<ManagedSessionRecord, NodeFailure> {
8012        validate_display_name(&display_name)
8013            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8014        identity
8015            .validate()
8016            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8017        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8018            return Err(failure(
8019                NodeFailureCode::InvalidRequest,
8020                "provider session reference index v1 does not accept transcript paths",
8021            ));
8022        }
8023        let canonical_root = self.workspace_root(&workspace_id)?;
8024        if !self.enabled_providers.contains(&provider) {
8025            return Err(failure(
8026                NodeFailureCode::InvalidRequest,
8027                "provider is not enabled on this node",
8028            ));
8029        }
8030
8031        let transaction = self
8032            .state_transaction
8033            .lock()
8034            .unwrap_or_else(|poisoned| poisoned.into_inner());
8035        {
8036            let records = self
8037                .session_records
8038                .lock()
8039                .unwrap_or_else(|poisoned| poisoned.into_inner());
8040            if let Some(existing) = records.records.values().find(|candidate| {
8041                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8042                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8043                })
8044            }) {
8045                if existing.provider == provider && existing.workspace_id == workspace_id {
8046                    return Ok(existing.clone());
8047                }
8048                return Err(failure(
8049                    NodeFailureCode::SessionRecordConflict,
8050                    "provider session reference conflicts with another provider or workspace",
8051                ));
8052            }
8053            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8054                return Err(failure(
8055                    NodeFailureCode::BackendBusy,
8056                    "managed session record capacity is full; forget an inactive record first",
8057                ));
8058            }
8059        }
8060
8061        let now = unix_time_ms();
8062        let record = ManagedSessionRecord {
8063            record_id: self.allocate_record_id()?,
8064            display_name,
8065            provider,
8066            mode: SessionMode::Pty,
8067            state: ManagedSessionState::Dormant,
8068            workspace_id,
8069            canonical_root: opaque_windows_path(canonical_root),
8070            provider_session: Some(identity),
8071            active_session: None,
8072            environment_profile: None,
8073            bundle: None,
8074            context_id: None,
8075            context: None,
8076            exported_context: None,
8077            task_binding: None,
8078            created_at_unix_ms: now,
8079            updated_at_unix_ms: now,
8080            last_error: None,
8081        };
8082        self.insert_record(record.clone())?;
8083        if let Err(error) = self.persist_state_locked() {
8084            self.remove_record_memory(&record.record_id);
8085            return Err(persistence_failure(error));
8086        }
8087        drop(transaction);
8088        self.publish_record(record.clone());
8089        Ok(record)
8090    }
8091
8092    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8093        self.session_bindings
8094            .lock()
8095            .unwrap_or_else(|poisoned| poisoned.into_inner())
8096            .iter()
8097            .find_map(|(instance_id, binding)| {
8098                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8099                    workspace_id: binding.workspace_id.clone(),
8100                    session: SessionKey {
8101                        instance_id: *instance_id,
8102                        generation: binding.generation,
8103                    },
8104                })
8105            })
8106    }
8107
8108    async fn forget_session_record(
8109        &self,
8110        record_id: &SessionRecordId,
8111    ) -> Result<(), NodeFailure> {
8112        let record = self.record(record_id)?;
8113        if record.active_session.is_some()
8114            || matches!(
8115                record.state,
8116                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8117            )
8118        {
8119            return Err(failure(
8120                NodeFailureCode::SessionRecordBusy,
8121                "stop the managed session before forgetting it",
8122            ));
8123        }
8124        if let Some(address) = self.bound_address_for_record(record_id) {
8125            self.remove_session(&address).await?;
8126        }
8127        self.remove_managed_session_record(record_id, &record.workspace_id).await
8128    }
8129
8130    /// Removes one managed session record from durable state and notifies
8131    /// every listener -- the shared transaction body behind both
8132    /// `forget_session_record`'s operator-driven path (busy-checked above
8133    /// it, and clears any still-live session binding before calling in
8134    /// here) and the retention sweep (`retire_unavailable_session_records`,
8135    /// which only ever selects `ManagedSessionState::Unavailable` records
8136    /// that are already known never-busy and never bound, so it calls
8137    /// straight into this with neither check). Cleans a managed-worktree
8138    /// lease if the record held one, removes the record's environment
8139    /// materialization (or, absent one, removes it from memory directly),
8140    /// persists under `state_transaction` with rollback on persist failure,
8141    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8142    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8143    /// exactly as it does for an operator-driven forget.
8144    async fn remove_managed_session_record(
8145        &self,
8146        record_id: &SessionRecordId,
8147        workspace_id: &WorkspaceId,
8148    ) -> Result<(), NodeFailure> {
8149        let managed_lease_id = self.managed_worktrees.lock()
8150            .unwrap_or_else(|poisoned| poisoned.into_inner())
8151            .lease_for_workspace(workspace_id);
8152        if !self.destroy_record_materialization(record_id)? {
8153            let transaction = self
8154                .state_transaction
8155                .lock()
8156                .unwrap_or_else(|poisoned| poisoned.into_inner());
8157            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8158                failure(
8159                    NodeFailureCode::UnknownSessionRecord,
8160                    "managed session record does not exist",
8161                )
8162            })?;
8163            if let Err(error) = self.persist_state_locked() {
8164                let _ = self.insert_record(removed);
8165                return Err(persistence_failure(error));
8166            }
8167            drop(transaction);
8168        }
8169        self.publish(NodeEvent::SessionRecordRemoved {
8170            record_id: record_id.clone(),
8171        });
8172        if let Some(lease_id) = managed_lease_id {
8173            if let Some(snapshot) = self.managed_worktrees.lock()
8174                .unwrap_or_else(|poisoned| poisoned.into_inner())
8175                .get(&lease_id)
8176                .map(ManagedWorktreeLeaseRecord::snapshot)
8177            {
8178                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8179            }
8180            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8181        }
8182        Ok(())
8183    }
8184
8185    /// Retires `ManagedSessionState::Unavailable` records against the
8186    /// node's configured `session_record_retention` age/keep-N policy.
8187    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8188    /// `Dormant` is resumable and folding it into this sweep would delete
8189    /// records a caller can still act on. Both policy fields default to
8190    /// `0` (disabled); when both are `0` this is a no-op. When both are
8191    /// set, a record is retired only if it fails BOTH tests (older than
8192    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8193    /// workspace); when only one is set, that one alone decides. Called
8194    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8195    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8196    /// loop -- see both call sites.
8197    async fn retire_unavailable_session_records(&self) {
8198        let policy = self.session_record_retention;
8199        let age_enabled = policy.age_ms > 0;
8200        let keep_enabled = policy.keep_per_workspace > 0;
8201        if !age_enabled && !keep_enabled {
8202            return;
8203        }
8204        let now = unix_time_ms();
8205        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8206            self.session_records
8207                .lock()
8208                .unwrap_or_else(|poisoned| poisoned.into_inner())
8209                .records
8210                .values()
8211                .filter(|record| record.state == ManagedSessionState::Unavailable)
8212                .map(|record| {
8213                    (
8214                        record.record_id.clone(),
8215                        record.workspace_id.clone(),
8216                        record.provider.clone(),
8217                        record.updated_at_unix_ms,
8218                    )
8219                })
8220                .collect()
8221        };
8222        if candidates.is_empty() {
8223            return;
8224        }
8225        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8226            BTreeMap::new();
8227        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8228            by_workspace
8229                .entry(workspace_id)
8230                .or_default()
8231                .push((record_id, provider, updated_at_unix_ms));
8232        }
8233        let reason = if age_enabled && keep_enabled {
8234            "age+keep-n"
8235        } else if age_enabled {
8236            "age"
8237        } else {
8238            "keep-n"
8239        };
8240        let mut retired: u64 = 0;
8241        for (workspace_id, mut records) in by_workspace {
8242            // Newest `updated_at_unix_ms` first, so index `>=
8243            // keep_per_workspace` names exactly "beyond the newest N".
8244            records.sort_by(|left, right| right.2.cmp(&left.2));
8245            for (index, (record_id, provider, updated_at_unix_ms)) in
8246                records.into_iter().enumerate()
8247            {
8248                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8249                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8250                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8251                let should_retire = if age_enabled && keep_enabled {
8252                    age_hit && keep_hit
8253                } else {
8254                    age_hit || keep_hit
8255                };
8256                if !should_retire {
8257                    continue;
8258                }
8259                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8260                    Ok(()) => {
8261                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8262                        retired += 1;
8263                        tracing::info!(
8264                            node_id = %self.node_id,
8265                            record_id = %record_id,
8266                            workspace_id = %workspace_id,
8267                            provider = %provider,
8268                            age_ms = record_age_ms,
8269                            reason,
8270                            "managed session record retired"
8271                        );
8272                    }
8273                    Err(error) => tracing::warn!(
8274                        node_id = %self.node_id,
8275                        record_id = %record_id,
8276                        workspace_id = %workspace_id,
8277                        code = ?error.code,
8278                        cause = %error.message,
8279                        "session record retention sweep failed to retire a record"
8280                    ),
8281                }
8282            }
8283        }
8284        if retired > 0 {
8285            let remaining_unavailable = self
8286                .session_records
8287                .lock()
8288                .unwrap_or_else(|poisoned| poisoned.into_inner())
8289                .records
8290                .values()
8291                .filter(|record| record.state == ManagedSessionState::Unavailable)
8292                .count();
8293            tracing::info!(
8294                node_id = %self.node_id,
8295                retired,
8296                remaining_unavailable,
8297                "session record retention sweep completed"
8298            );
8299        }
8300    }
8301
8302    async fn resume_session_record(
8303        &self,
8304        record_id: &SessionRecordId,
8305        terminal_size: gate4agent_types::TerminalSize,
8306        initial_prompt: Option<String>,
8307    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8308        let record = self.record(record_id)?;
8309        if record.active_session.is_some()
8310            || matches!(
8311                record.state,
8312                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8313            )
8314        {
8315            return Err(failure(
8316                NodeFailureCode::SessionRecordBusy,
8317                "managed session already has a live runtime binding",
8318            ));
8319        }
8320        let identity = record.provider_session.clone().ok_or_else(|| {
8321            failure(
8322                NodeFailureCode::SessionRecordNotResumable,
8323                "managed session has no verified provider session identity",
8324            )
8325        })?;
8326        let working_directory = self.workspace_root(&record.workspace_id)?;
8327        if !platform::roots_equal(
8328            &working_directory,
8329            windows_path_text(&record.canonical_root),
8330        ) {
8331            return Err(failure(
8332                NodeFailureCode::SessionRecordConflict,
8333                "managed session workspace root changed; refusing to resume in another directory",
8334            ));
8335        }
8336        {
8337            let records = self
8338                .session_records
8339                .lock()
8340                .unwrap_or_else(|poisoned| poisoned.into_inner());
8341            if records.records.values().any(|candidate| {
8342                candidate.record_id != *record_id
8343                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8344                        session_registry::same_provider_session(
8345                            &candidate.provider,
8346                            candidate_identity,
8347                            &record.provider,
8348                            &identity,
8349                        )
8350                    })
8351                    && (candidate.active_session.is_some()
8352                        || matches!(
8353                            candidate.state,
8354                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8355                        ))
8356            }) {
8357                return Err(failure(
8358                    NodeFailureCode::SessionRecordConflict,
8359                    "provider session is already active through another managed record",
8360                ));
8361            }
8362        }
8363        let runtime_requirement = if initial_prompt.is_some() {
8364            ProviderRuntimeRequirement::ResumeWithPrompt
8365        } else {
8366            ProviderRuntimeRequirement::Resume
8367        };
8368        let runtime_policy = self.admit_provider_runtime(
8369            &record.provider,
8370            runtime_requirement,
8371        )
8372        .await?;
8373        let request = ResumeLaunchRequest {
8374            working_directory,
8375            terminal_size,
8376            initial_prompt,
8377        };
8378        request
8379            .validate()
8380            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8381        self.ensure_binding_capacity()?;
8382        let resolved_materialization = match self.resolve_record_materialization(
8383            record_id,
8384            &record.provider,
8385            record.mode,
8386            record.environment_profile.as_ref(),
8387            record.bundle.as_ref(),
8388            record.context.as_ref(),
8389        ) {
8390            Ok(materialization) => materialization,
8391            Err(error) => {
8392                let label = match error.code {
8393                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8394                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8395                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8396                    _ => "environment-profile-unavailable",
8397                };
8398                self.mark_record_error(record_id, label)?;
8399                return Err(error);
8400            }
8401        };
8402        let (materialization_id, environment_overlay) = match resolved_materialization {
8403            Some((id, overlay)) => (Some(id), overlay),
8404            None => (None, None),
8405        };
8406        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8407        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8408            instance_id,
8409            &record.provider,
8410            record.mode,
8411            record.environment_profile.as_ref(),
8412        ) {
8413            Ok(selection) => selection,
8414            Err(error) => {
8415                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8416                return Err(error);
8417            }
8418        };
8419        let mut instance_overlay = environment_overlay
8420            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8421            .transpose()?
8422            .flatten();
8423        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8424            self.remove_session(&stale_address).await?;
8425        }
8426        let address = SessionAddress {
8427            workspace_id: record.workspace_id.clone(),
8428            session: SessionKey {
8429                instance_id,
8430                generation: SessionGeneration(1),
8431            },
8432        };
8433        let transaction = self
8434            .state_transaction
8435            .lock()
8436            .unwrap_or_else(|poisoned| poisoned.into_inner());
8437        let current = self.record(record_id)?;
8438        if current.active_session.is_some()
8439            || matches!(
8440                current.state,
8441                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8442            )
8443        {
8444            return Err(failure(
8445                NodeFailureCode::SessionRecordBusy,
8446                "managed session became active while resume was being prepared",
8447            ));
8448        }
8449        self.bind_managed_session_with_materialization(
8450            &address,
8451            record_id.clone(),
8452            runtime_policy,
8453            record.environment_profile.clone(),
8454            record.bundle.clone(),
8455            record.context.clone(),
8456            materialization_id,
8457        );
8458        if let Some(selection) = environment_selection.take() {
8459            selection.retain();
8460        }
8461        if let Some(overlay) = instance_overlay.take() {
8462            overlay.retain();
8463        }
8464        drop(transaction);
8465
8466        let transport = match record.mode {
8467            SessionMode::Pty => TransportKind::Pty,
8468            SessionMode::Inline => TransportKind::Pipe,
8469            SessionMode::Acp => TransportKind::Acp,
8470        };
8471        let agent_id = record.provider.clone();
8472        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8473        if let Err(error) = self
8474            .dispatch_bounded(
8475                ControlCommand::Register {
8476                    instance_id,
8477                    agent_id,
8478                    transport,
8479                },
8480                dispatch_timeout,
8481            )
8482            .await
8483        {
8484            if let Some(binding) = self.remove_binding(&address) {
8485                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8486            }
8487            return Err(error);
8488        }
8489        let command = self.prepare_command(ControlCommand::Resume {
8490            instance_id,
8491            target: ResumeTarget::ProviderSession { identity },
8492            runtime_policy,
8493            request,
8494        });
8495        let started_after = self.current_sequence();
8496        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8497            self.rollback_spawn(&address, dispatch_timeout).await?;
8498            return Err(error);
8499        }
8500        if let Err(error) = self.dispatch_envelope(command) {
8501            self.clear_armed_resume(&address);
8502            self.rollback_spawn(&address, dispatch_timeout).await?;
8503            return Err(error);
8504        }
8505        match self
8506            .wait_for_record_resume(&address, started_after)
8507            .await
8508        {
8509            Ok(settled_address) => {
8510                let managed_lease_id = {
8511                    self.managed_worktrees.lock()
8512                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8513                        .lease_for_workspace(&record.workspace_id)
8514                };
8515                if let Some(lease_id) = managed_lease_id {
8516                    {
8517                        let mut bindings = self.session_bindings.lock()
8518                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8519                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8520                            .ok_or_else(|| failure(
8521                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8522                                "resumed managed session lost its exact binding",
8523                            ))?;
8524                        if binding.generation != settled_address.session.generation {
8525                            return Err(failure(
8526                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8527                                "resumed managed session generation diverged",
8528                            ));
8529                        }
8530                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8531                    }
8532                    let snapshot = self.managed_worktrees.lock()
8533                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8534                        .bind_session(
8535                            &lease_id,
8536                            ManagedWorktreeSessionHolder {
8537                                incarnation_id: self.incarnation_id,
8538                                instance_id: settled_address.session.instance_id,
8539                                generation: settled_address.session.generation,
8540                            },
8541                            Some(record_id.clone()),
8542                            unix_time_ms(),
8543                        ).map_err(|message| failure(
8544                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8545                            &message,
8546                        ))?;
8547                    if let Err(error) = self.persist_state() {
8548                        let _ = self.remove_session(&settled_address).await;
8549                        return Err(persistence_failure(error));
8550                    }
8551                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8552                }
8553                Ok((self.record(record_id)?, settled_address))
8554            }
8555            Err(error) => {
8556                if matches!(
8557                    error.code,
8558                    NodeFailureCode::SessionRecordNotResumable
8559                        | NodeFailureCode::BackendOperationFailed
8560                ) {
8561                    if let Some(bound) = self.bound_address_for_record(record_id) {
8562                        let _ = self.remove_session(&bound).await;
8563                    }
8564                }
8565                Err(error)
8566            }
8567        }
8568    }
8569
8570    async fn wait_for_record_resume(
8571        &self,
8572        initial_address: &SessionAddress,
8573        after_sequence: u64,
8574    ) -> Result<SessionAddress, NodeFailure> {
8575        let deadline = Instant::now()
8576            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8577        let mut scan_after = after_sequence;
8578        loop {
8579            let events = self
8580                .history
8581                .lock()
8582                .unwrap_or_else(|poisoned| poisoned.into_inner())
8583                .events
8584                .iter()
8585                .filter(|event| event.sequence > scan_after)
8586                .cloned()
8587                .collect::<Vec<_>>();
8588            for envelope in events {
8589                scan_after = scan_after.max(envelope.sequence);
8590                let NodeEvent::Control { address, event } = envelope.event else {
8591                    continue;
8592                };
8593                if address.session.instance_id != initial_address.session.instance_id {
8594                    continue;
8595                }
8596                match event.event {
8597                    ControlEventKind::Resumed { .. } => return Ok(address),
8598                    ControlEventKind::ResumeDenied { reason } => {
8599                        return Err(failure(
8600                            NodeFailureCode::SessionRecordNotResumable,
8601                            &reason,
8602                        ));
8603                    }
8604                    ControlEventKind::ResumeFailed { message }
8605                    | ControlEventKind::CommandRejected { message } => {
8606                        return Err(failure(
8607                            NodeFailureCode::BackendOperationFailed,
8608                            &message,
8609                        ));
8610                    }
8611                    _ => {}
8612                }
8613            }
8614            if Instant::now() >= deadline {
8615                return Err(failure(
8616                    NodeFailureCode::BackendBusy,
8617                    "managed resume did not settle before the bounded deadline",
8618                ));
8619            }
8620            sleep(Duration::from_millis(5)).await;
8621        }
8622    }
8623
8624    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8625        self.workspaces
8626            .read()
8627            .unwrap_or_else(|poisoned| poisoned.into_inner())
8628            .get(workspace_id)
8629            .cloned()
8630            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8631    }
8632
8633    fn workspace_roots(&self) -> Vec<PathBuf> {
8634        self.workspaces
8635            .read()
8636            .unwrap_or_else(|poisoned| poisoned.into_inner())
8637            .values()
8638            .map(PathBuf::from)
8639            .collect()
8640    }
8641
8642    async fn inspect_workspace(
8643        &self,
8644        workspace_id: WorkspaceId,
8645    ) -> Result<WorkspaceInspection, NodeFailure> {
8646        let _permit = self
8647            .inspection_slots
8648            .clone()
8649            .try_acquire_owned()
8650            .map_err(|_| {
8651                tracing::warn!(
8652                    workspace_id = %workspace_id,
8653                    "workspace inspection rejected: inspection capacity is busy",
8654                );
8655                failure(
8656                    NodeFailureCode::BackendBusy,
8657                    "workspace inspection capacity is busy",
8658                )
8659            })?;
8660        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8661            tracing::warn!(
8662                workspace_id = %workspace_id,
8663                code = ?error.code,
8664                "workspace inspection rejected: workspace is not registered",
8665            );
8666            error
8667        })?;
8668        let tree_root = canonical_root.clone();
8669        let started_at = Instant::now();
8670        let time_budget_ms = workspace_inspection_time_budget_ms();
8671        let entry_cap = workspace_inspection_entry_cap();
8672        let deadline = started_at + Duration::from_millis(time_budget_ms);
8673        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8674            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8675        })
8676        .await
8677        .map_err(|error| {
8678            tracing::warn!(
8679                workspace_id = %workspace_id,
8680                error = %error,
8681                "workspace inspection rejected: walk task failed",
8682            );
8683            failure(
8684                NodeFailureCode::BackendOperationFailed,
8685                &format!("workspace inspection task failed: {error}"),
8686            )
8687        })?;
8688        let (mut git, git_time_budget_exceeded) =
8689            inspect_git_workspace(&canonical_root, deadline).await;
8690        if !git.worktrees.is_empty() {
8691            let registered = self
8692                .workspaces
8693                .read()
8694                .unwrap_or_else(|poisoned| poisoned.into_inner())
8695                .iter()
8696                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8697                .collect::<Vec<_>>();
8698            for worktree in &mut git.worktrees {
8699                worktree.workspace_id = registered
8700                    .iter()
8701                    .find(|(_, root)| {
8702                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8703                    })
8704                    .map(|(workspace_id, _)| workspace_id.clone());
8705            }
8706        }
8707        if git.is_repository {
8708            git.managed_worktree = self
8709                .managed_worktrees
8710                .lock()
8711                .unwrap_or_else(|poisoned| poisoned.into_inner())
8712                .git_scope(&workspace_id, git.branch.as_deref());
8713        }
8714        let truncation = workspace_inspection_truncation(
8715            &walk_budget,
8716            git_time_budget_exceeded,
8717            started_at.elapsed(),
8718        );
8719        if let Some(truncation) = &truncation {
8720            tracing::info!(
8721                workspace_id = %workspace_id,
8722                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8723                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8724                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8725                entries_visited = truncation.entries_visited,
8726                elapsed_ms = truncation.elapsed_ms,
8727                "workspace inspection truncated by its inner budget",
8728            );
8729        }
8730        Ok(WorkspaceInspection {
8731            workspace_id,
8732            entries,
8733            tree_truncated,
8734            git,
8735            truncation,
8736        })
8737    }
8738
8739    async fn browse_host_directories(
8740        &self,
8741        directory: Option<OpaqueHostPath>,
8742        after: Option<OpaqueHostPath>,
8743    ) -> Result<HostDirectoryListing, NodeFailure> {
8744        let permit = self
8745            .inspection_slots
8746            .clone()
8747            .try_acquire_owned()
8748            .map_err(|_| {
8749                failure(
8750                    NodeFailureCode::BackendBusy,
8751                    "host directory browse capacity is busy",
8752                )
8753            })?;
8754        let task = tokio::task::spawn_blocking(move || {
8755            let _permit = permit;
8756            browse_host_directories(directory, after)
8757        });
8758        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8759            .await
8760            .map_err(|_| {
8761                failure(
8762                    NodeFailureCode::HostDirectoryReadTimedOut,
8763                    "host directory browse exceeded its bounded deadline",
8764                )
8765            })?
8766            .map_err(|_| {
8767                failure(
8768                    NodeFailureCode::HostDirectoryReadFailed,
8769                    "host directory browse task failed",
8770                )
8771            })?
8772            .map_err(host_directory_failure)
8773    }
8774
8775    async fn read_workspace_file(
8776        &self,
8777        workspace_id: WorkspaceId,
8778        path: RepositoryPath,
8779    ) -> Result<WorkspaceFileRead, NodeFailure> {
8780        let canonical_root = self.workspace_root(&workspace_id)?;
8781        let permit = self
8782            .inspection_slots
8783            .clone()
8784            .try_acquire_owned()
8785            .map_err(|_| {
8786                failure(
8787                    NodeFailureCode::BackendBusy,
8788                    "workspace file read capacity is busy",
8789                )
8790            })?;
8791        let path_to_read = path.clone();
8792        let task = tokio::task::spawn_blocking(move || {
8793            let _permit = permit;
8794            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8795        });
8796        let content = timeout(
8797            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8798            task,
8799        )
8800        .await
8801        .map_err(|_| {
8802            failure(
8803                NodeFailureCode::RepositoryFileReadTimedOut,
8804                "repository file read exceeded its bounded deadline",
8805            )
8806        })?
8807        .map_err(|_| {
8808            failure(
8809                NodeFailureCode::RepositoryFileReadFailed,
8810                "repository file read task failed",
8811            )
8812        })?
8813        .map_err(workspace_file_failure)?;
8814        let content = match content {
8815            WorkspaceFileBytes::Utf8(text) => {
8816                let revision = Some(workspace_file_revision(text.as_bytes()));
8817                return Ok(WorkspaceFileRead {
8818                    workspace_id,
8819                    path,
8820                    content: WorkspaceFileContent::Utf8 {
8821                        byte_len: u32::try_from(text.len())
8822                            .expect("bounded workspace text length must fit u32"),
8823                        text,
8824                    },
8825                    revision,
8826                });
8827            }
8828            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8829                WorkspaceFileContent::NonUtf8 {
8830                    byte_len: u32::try_from(byte_length)
8831                        .expect("bounded workspace file length must fit u32"),
8832                }
8833            }
8834            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8835                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8836            },
8837        };
8838        Ok(WorkspaceFileRead {
8839            workspace_id,
8840            path,
8841            content,
8842            revision: None,
8843        })
8844    }
8845
8846    async fn write_workspace_file(
8847        &self,
8848        workspace_id: WorkspaceId,
8849        path: RepositoryPath,
8850        expected_revision: WorkspaceFileRevision,
8851        text: String,
8852    ) -> Result<WorkspaceFileRead, NodeFailure> {
8853        let canonical_root = self.workspace_root(&workspace_id)?;
8854        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8855            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8856        })?;
8857        let path_to_write = path.clone();
8858        let expected = expected_revision.as_str().to_owned();
8859        let text_to_write = text.clone();
8860        let task = tokio::task::spawn_blocking(move || {
8861            let _permit = permit;
8862            write_workspace_file_to_disk(
8863                Path::new(&canonical_root),
8864                &path_to_write,
8865                &expected,
8866                &text_to_write,
8867            )
8868        });
8869        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8870            .await
8871            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8872            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8873            .map_err(workspace_file_write_failure)?;
8874        Ok(WorkspaceFileRead {
8875            workspace_id,
8876            path,
8877            content: WorkspaceFileContent::Utf8 {
8878                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8879                text,
8880            },
8881            revision: Some(WorkspaceFileRevision::new(revision)
8882                .expect("workspace writer returns a valid SHA-256 revision")),
8883        })
8884    }
8885
8886    async fn create_workspace_file(
8887        &self,
8888        workspace_id: WorkspaceId,
8889        path: RepositoryPath,
8890    ) -> Result<WorkspaceFileRead, NodeFailure> {
8891        let canonical_root = self.workspace_root(&workspace_id)?;
8892        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8893            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8894        })?;
8895        let path_to_create = path.clone();
8896        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8897        let task_commit_state = Arc::clone(&commit_state);
8898        let task = tokio::task::spawn_blocking(move || {
8899            let _permit = permit;
8900            create_workspace_file_on_disk(
8901                Path::new(&canonical_root),
8902                &path_to_create,
8903                &task_commit_state,
8904            )
8905        });
8906        let revision = settle_workspace_entry_create(
8907            task,
8908            commit_state,
8909            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8910            "repository file create exceeded its bounded deadline",
8911            "repository file create task failed",
8912        )
8913        .await?;
8914        Ok(WorkspaceFileRead {
8915            workspace_id,
8916            path,
8917            content: WorkspaceFileContent::Utf8 {
8918                text: String::new(),
8919                byte_len: 0,
8920            },
8921            revision: Some(WorkspaceFileRevision::new(revision)
8922                .expect("workspace creator returns a valid SHA-256 revision")),
8923        })
8924    }
8925
8926    async fn create_workspace_directory(
8927        &self,
8928        workspace_id: WorkspaceId,
8929        path: RepositoryPath,
8930    ) -> Result<WorkspaceEntry, NodeFailure> {
8931        let canonical_root = self.workspace_root(&workspace_id)?;
8932        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8933            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8934        })?;
8935        let path_to_create = path.clone();
8936        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8937        let task_commit_state = Arc::clone(&commit_state);
8938        let task = tokio::task::spawn_blocking(move || {
8939            let _permit = permit;
8940            create_workspace_directory_on_disk(
8941                Path::new(&canonical_root),
8942                &path_to_create,
8943                &task_commit_state,
8944            )
8945        });
8946        settle_workspace_entry_create(
8947            task,
8948            commit_state,
8949            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8950            "repository directory create exceeded its bounded deadline",
8951            "repository directory create task failed",
8952        )
8953        .await?;
8954        Ok(WorkspaceEntry {
8955            relative_path: path,
8956            kind: WorkspaceEntryKind::Directory,
8957        })
8958    }
8959
8960    async fn read_git_history(
8961        &self,
8962        workspace_id: WorkspaceId,
8963        path: Option<RepositoryPath>,
8964        before: Option<GitObjectId>,
8965        limit: u16,
8966    ) -> Result<GitHistoryPage, NodeFailure> {
8967        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8968            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8969        })?;
8970        let root = self.workspace_root(&workspace_id)?;
8971        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8972    }
8973
8974    async fn read_git_diff(
8975        &self,
8976        workspace_id: WorkspaceId,
8977        request: GitDiffRequest,
8978    ) -> Result<GitDiff, NodeFailure> {
8979        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8980            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8981        })?;
8982        let root = self.workspace_root(&workspace_id)?;
8983        read_git_diff_bounded(&root, request).await
8984    }
8985
8986    fn native_session_route_root(
8987        &self,
8988        route: &NativeSessionCatalogRoute,
8989    ) -> Result<Option<String>, NodeFailure> {
8990        route
8991            .validate()
8992            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8993        match route.scope {
8994            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8995                .workspace_id
8996                .as_ref()
8997                .map(|workspace_id| self.workspace_root(workspace_id))
8998                .transpose(),
8999            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9000        }
9001    }
9002
9003    fn project_native_session_entries(
9004        &self,
9005        route: &NativeSessionCatalogRoute,
9006        entries: Vec<ScopedNativeSessionCatalogEntry>,
9007    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9008        let current_root = route
9009            .workspace_id
9010            .as_ref()
9011            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9012        let records = self
9013            .session_records
9014            .lock()
9015            .unwrap_or_else(|poisoned| poisoned.into_inner());
9016        let mut seen_records = std::collections::HashSet::new();
9017        entries
9018            .into_iter()
9019            .map(|entry| {
9020                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9021                    let matches = records
9022                        .records
9023                        .values()
9024                        .filter(|record| {
9025                            record.provider == route.provider
9026                                && record.workspace_id == *workspace_id
9027                                && current_root.as_deref().is_some_and(|root| {
9028                                    platform::roots_equal(
9029                                        root,
9030                                        windows_path_text(&record.canonical_root),
9031                                    )
9032                                })
9033                                && record.provider_session.as_ref().is_some_and(|identity| {
9034                                    session_registry::same_provider_session(
9035                                        &record.provider,
9036                                        identity,
9037                                        &route.provider,
9038                                        &entry.provider_session,
9039                                    )
9040                                })
9041                        })
9042                        .map(|record| record.record_id.clone())
9043                        .collect::<Vec<_>>();
9044                    (matches.len() == 1
9045                        && seen_records.insert(matches[0].clone()))
9046                        .then(|| matches[0].clone())
9047                });
9048                let metadata = entry.metadata;
9049                crate::protocol::NativeSessionCatalogEntry {
9050                    selection_id: metadata.selection_id,
9051                    title: metadata.title,
9052                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9053                    model: metadata.model,
9054                    message_count: metadata.message_count,
9055                    completed_turn_count: metadata.completed_turn_count,
9056                    external_group: entry.external_group,
9057                    record_id,
9058                }
9059            })
9060            .collect()
9061    }
9062
9063    async fn catalog_native_sessions(
9064        &self,
9065        route: NativeSessionCatalogRoute,
9066        limit: u16,
9067    ) -> Result<(
9068        Vec<crate::protocol::NativeSessionCatalogEntry>,
9069        crate::protocol::NativeSessionCatalogSummary,
9070    ), NodeFailure> {
9071        let canonical_root = self.native_session_route_root(&route)?;
9072        let registered_workspace_roots = self.workspace_roots();
9073        let provider = route.provider.clone();
9074        let scope = route.scope;
9075        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9076            failure(
9077                NodeFailureCode::InvalidRequest,
9078                "native session catalog is not configured",
9079            )
9080        })?;
9081        let _permit = Arc::clone(&self.inspection_slots)
9082            .try_acquire_owned()
9083            .map_err(|_| {
9084                failure(
9085                    NodeFailureCode::BackendBusy,
9086                    "native session catalog capacity is busy",
9087                )
9088            })?;
9089        let page = run_native_session_catalog_operation(
9090            catalog,
9091            "native session catalog",
9092            move |catalog| {
9093                catalog.catalog_initial_for_scope(
9094                    &provider,
9095                    scope,
9096                    canonical_root.as_deref().map(Path::new),
9097                    &registered_workspace_roots,
9098                    limit,
9099                )
9100            },
9101        )
9102        .await?;
9103        let summary = crate::protocol::NativeSessionCatalogSummary {
9104            catalog_revision: page.revision,
9105            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9106            recent_total_count: page.recent_total_count,
9107            older_total_count: page.older_total_count,
9108            recent_next_after_selection_id: page.next_after_selection_id,
9109            recent_has_more: page.remaining_count > 0,
9110        };
9111        Ok((self.project_native_session_entries(&route, page.entries), summary))
9112    }
9113
9114    async fn page_native_sessions(
9115        &self,
9116        route: NativeSessionCatalogRoute,
9117        window: crate::protocol::NativeSessionCatalogWindow,
9118        catalog_revision: u64,
9119        recent_cutoff_unix_ms: u64,
9120        after_selection_id: Option<String>,
9121        limit: u16,
9122    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9123        let canonical_root = self.native_session_route_root(&route)?;
9124        let registered_workspace_roots = self.workspace_roots();
9125        let provider = route.provider.clone();
9126        let scope = route.scope;
9127        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9128            failure(
9129                NodeFailureCode::InvalidRequest,
9130                "native session catalog is not configured",
9131            )
9132        })?;
9133        let _permit = Arc::clone(&self.inspection_slots)
9134            .try_acquire_owned()
9135            .map_err(|_| {
9136                failure(
9137                    NodeFailureCode::BackendBusy,
9138                    "native session catalog capacity is busy",
9139                )
9140            })?;
9141        let page = run_native_session_catalog_operation(
9142            catalog,
9143            "native session catalog page",
9144            move |catalog| {
9145                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9146                    &provider,
9147                    scope,
9148                    canonical_root.as_deref().map(Path::new),
9149                    &registered_workspace_roots,
9150                    window,
9151                    catalog_revision,
9152                    recent_cutoff_unix_ms,
9153                    after_selection_id.as_deref(),
9154                    limit,
9155                ))
9156            },
9157        )
9158        .await?;
9159        let page = page.map_err(|error| match error {
9160            NativeSessionCatalogError::StaleCatalog => failure(
9161                NodeFailureCode::StaleNativeSessionCatalog,
9162                "native session catalog revision or cursor is stale",
9163            ),
9164            _ => failure(
9165                NodeFailureCode::BackendOperationFailed,
9166                "native session catalog page failed",
9167            ),
9168        })?;
9169        let entries = self.project_native_session_entries(&route, page.entries);
9170        Ok(crate::protocol::NativeSessionCatalogPage {
9171            window: page.window,
9172            revision: page.revision,
9173            entries,
9174            next_after_selection_id: page.next_after_selection_id,
9175            remaining_count: page.remaining_count,
9176            has_more: page.remaining_count > 0,
9177        })
9178    }
9179
9180    async fn preview_native_session(
9181        &self,
9182        selection: NativeSessionSelection,
9183        message_limit: u16,
9184    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9185        selection
9186            .validate()
9187            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9188        let canonical_root = self.native_session_route_root(&selection.route)?;
9189        let registered_workspace_roots = self.workspace_roots();
9190        let provider = selection.route.provider.clone();
9191        let scope = selection.route.scope;
9192        let catalog_revision = selection.catalog_revision;
9193        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9194        let selection_id = selection.selection_id.clone();
9195        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9196            failure(
9197                NodeFailureCode::InvalidRequest,
9198                "native session preview is not configured",
9199            )
9200        })?;
9201        let _permit = Arc::clone(&self.inspection_slots)
9202            .try_acquire_owned()
9203            .map_err(|_| {
9204                failure(
9205                    NodeFailureCode::BackendBusy,
9206                    "native session preview capacity is busy",
9207                )
9208            })?;
9209        let preview = run_native_session_catalog_operation(
9210            catalog,
9211            "native session preview",
9212            move |catalog| {
9213                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9214                    &provider,
9215                    scope,
9216                    canonical_root.as_deref().map(Path::new),
9217                    &registered_workspace_roots,
9218                    catalog_revision,
9219                    recent_cutoff_unix_ms,
9220                    &selection_id,
9221                    message_limit,
9222                ))
9223            },
9224        )
9225        .await?;
9226        preview
9227            .map(Into::into)
9228            .map_err(native_session_preview_failure)
9229    }
9230
9231    async fn index_native_session(
9232        &self,
9233        selection: NativeSessionSelection,
9234        display_name: String,
9235    ) -> Result<ManagedSessionRecord, NodeFailure> {
9236        selection
9237            .validate()
9238            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9239        if selection.route.scope
9240            != gate4agent_types::NativeSessionCatalogScope::Workspace
9241        {
9242            return Err(failure(
9243                NodeFailureCode::WorkspaceRegistrationRequired,
9244                "register the external project as a workspace before indexing or resuming it",
9245            ));
9246        }
9247        let workspace_id = selection
9248            .route
9249            .workspace_id
9250            .clone()
9251            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9252        let canonical_root = self
9253            .native_session_route_root(&selection.route)?
9254            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9255        let registered_workspace_roots = self.workspace_roots();
9256        let provider = selection.route.provider.clone();
9257        let catalog_revision = selection.catalog_revision;
9258        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9259        let selection_id = selection.selection_id.clone();
9260        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9261            failure(
9262                NodeFailureCode::InvalidRequest,
9263                "native session index is not configured",
9264            )
9265        })?;
9266        let _permit = Arc::clone(&self.inspection_slots)
9267            .try_acquire_owned()
9268            .map_err(|_| {
9269                failure(
9270                    NodeFailureCode::BackendBusy,
9271                    "native session index capacity is busy",
9272                )
9273            })?;
9274        let resolved = run_native_session_catalog_operation(
9275            catalog,
9276            "native session index resolution",
9277            move |catalog| {
9278                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9279                    &provider,
9280                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9281                    Some(Path::new(&canonical_root)),
9282                    &registered_workspace_roots,
9283                    catalog_revision,
9284                    recent_cutoff_unix_ms,
9285                    &selection_id,
9286                ))
9287            },
9288        )
9289        .await?
9290        .map_err(native_session_preview_failure)?;
9291        self.index_provider_session_with_policy(
9292            workspace_id,
9293            selection.route.provider,
9294            resolved.identity,
9295            display_name,
9296            true,
9297        )
9298    }
9299
9300    async fn preview_native_session_by_session_id(
9301        &self,
9302        workspace_id: WorkspaceId,
9303        provider: AgentId,
9304        session_id: String,
9305        message_limit: u16,
9306    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9307        let canonical_root = self.workspace_root(&workspace_id)?;
9308        let registered_workspace_roots = self.workspace_roots();
9309        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9310            failure(
9311                NodeFailureCode::InvalidRequest,
9312                "native session preview is not configured",
9313            )
9314        })?;
9315        let _permit = Arc::clone(&self.inspection_slots)
9316            .try_acquire_owned()
9317            .map_err(|_| {
9318                failure(
9319                    NodeFailureCode::BackendBusy,
9320                    "native session preview capacity is busy",
9321                )
9322            })?;
9323        run_native_session_catalog_operation(
9324            catalog,
9325            "native session preview",
9326            move |catalog| {
9327                catalog.preview_session_id_for_workspace(
9328                    &provider,
9329                    Path::new(&canonical_root),
9330                    &registered_workspace_roots,
9331                    &session_id,
9332                    message_limit,
9333                )
9334            },
9335        )
9336        .await
9337    }
9338
9339    async fn register_workspace(
9340        &self,
9341        workspace_id: WorkspaceId,
9342        root: String,
9343    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9344        validate_workspace_request_root(&root)?;
9345        let canonicalize_workspace_id = workspace_id.clone();
9346        let workspace = tokio::task::spawn_blocking(move || {
9347            WorkspaceConfig::new(canonicalize_workspace_id, root)
9348        })
9349        .await
9350        .map_err(|error| {
9351            failure(
9352                NodeFailureCode::BackendOperationFailed,
9353                &format!("workspace canonicalization task failed: {error}"),
9354            )
9355        })?
9356        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9357        let transaction = self
9358            .state_transaction
9359            .lock()
9360            .unwrap_or_else(|poisoned| poisoned.into_inner());
9361        let mut workspaces = self
9362            .workspaces
9363            .write()
9364            .unwrap_or_else(|poisoned| poisoned.into_inner());
9365        if workspaces.contains_key(&workspace_id) {
9366            return Err(failure(
9367                NodeFailureCode::DuplicateWorkspaceId,
9368                "workspace ID is already registered",
9369            ));
9370        }
9371        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9372            platform::roots_equal(existing_root, workspace.canonical_root())
9373        }) {
9374            return Err(failure(
9375                NodeFailureCode::DuplicateWorkspaceRoot,
9376                &format!("workspace root is already registered as '{existing_id}'"),
9377            ));
9378        }
9379        {
9380            let records = self
9381                .session_records
9382                .lock()
9383                .unwrap_or_else(|poisoned| poisoned.into_inner());
9384            if let Some(record) = records.records.values().find(|record| {
9385                record.workspace_id == workspace_id
9386                    && !record
9387                        .canonical_root
9388                        .as_utf8()
9389                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9390            }) {
9391                return Err(failure(
9392                    NodeFailureCode::SessionRecordConflict,
9393                    &format!(
9394                        "workspace ID is retained by session record '{}' at another root",
9395                        record.record_id,
9396                    ),
9397                ));
9398            }
9399        }
9400        let snapshot = WorkspaceSnapshot {
9401            workspace_id: workspace_id.clone(),
9402            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9403            sessions: Vec::new(),
9404            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9405            managed_worktree_profiles: Some(WorktreeProfileInventory {
9406                profiles: Vec::new(),
9407            }),
9408        };
9409        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9410        drop(workspaces);
9411        let previous_records = {
9412            let mut records = self
9413                .session_records
9414                .lock()
9415                .unwrap_or_else(|poisoned| poisoned.into_inner());
9416            let previous = records.records.clone();
9417            for record in records.records.values_mut().filter(|record| {
9418                record.workspace_id == workspace_id
9419                    && record
9420                        .canonical_root
9421                        .as_utf8()
9422                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9423            }) {
9424                if record.active_session.is_none() {
9425                    record.state = detached_record_state(record);
9426                }
9427                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9428                    record.last_error = None;
9429                }
9430                record.updated_at_unix_ms = unix_time_ms();
9431            }
9432            previous
9433        };
9434        if let Err(error) = self.persist_state_locked() {
9435            self.workspaces
9436                .write()
9437                .unwrap_or_else(|poisoned| poisoned.into_inner())
9438                .remove(&workspace_id);
9439            self.session_records
9440                .lock()
9441                .unwrap_or_else(|poisoned| poisoned.into_inner())
9442                .records = previous_records;
9443            return Err(persistence_failure(error));
9444        }
9445        let restored = self
9446            .session_records
9447            .lock()
9448            .unwrap_or_else(|poisoned| poisoned.into_inner())
9449            .records
9450            .values()
9451            .filter(|record| record.workspace_id == workspace_id)
9452            .cloned()
9453            .collect::<Vec<_>>();
9454        drop(transaction);
9455        self.publish(NodeEvent::WorkspaceAdded {
9456            workspace: snapshot.clone(),
9457        });
9458        for record in restored {
9459            self.publish_record(record);
9460        }
9461        Ok(snapshot)
9462    }
9463
9464    async fn create_standalone_workspace(
9465        &self,
9466        workspace_id: WorkspaceId,
9467        root: String,
9468        initial_branch: Option<String>,
9469    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9470        validate_workspace_request_root(&root)?;
9471        validate_git_revision("initial branch", initial_branch.as_deref())?;
9472        let preflight_workspace_id = workspace_id.clone();
9473        let preflight_root = root.clone();
9474        let canonical_candidate = tokio::task::spawn_blocking(move || {
9475            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9476        })
9477        .await
9478        .map_err(|_| failure(
9479            NodeFailureCode::BackendOperationFailed,
9480            "standalone workspace root preflight task failed",
9481        ))?
9482        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9483        {
9484            let workspaces = self
9485                .workspaces
9486                .read()
9487                .unwrap_or_else(|poisoned| poisoned.into_inner());
9488            if workspaces.contains_key(&workspace_id) {
9489                return Err(failure(
9490                    NodeFailureCode::DuplicateWorkspaceId,
9491                    "workspace ID is already registered",
9492                ));
9493            }
9494            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9495                platform::roots_equal(existing_root, &canonical_candidate)
9496            }) {
9497                return Err(failure(
9498                    NodeFailureCode::DuplicateWorkspaceRoot,
9499                    &format!("workspace root is already registered as '{existing_id}'"),
9500                ));
9501            }
9502        }
9503        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9504            .await
9505            .map_err(standalone_workspace_failure)?;
9506        prepared
9507            .verify_for_registration()
9508            .await
9509            .map_err(standalone_workspace_failure)?;
9510        match self
9511            .register_workspace(workspace_id, prepared.root().to_owned())
9512            .await
9513        {
9514            Ok(workspace) => Ok(workspace),
9515            Err(registration_error) => {
9516                if prepared.compensate_registration_failure().await {
9517                    Err(registration_error)
9518                } else {
9519                    Err(failure(
9520                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9521                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9522                    ))
9523                }
9524            }
9525        }
9526    }
9527
9528    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9529        let bindings = self
9530            .session_bindings
9531            .lock()
9532            .unwrap_or_else(|poisoned| poisoned.into_inner());
9533        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9534            return Err(failure(
9535                NodeFailureCode::WorkspaceBusy,
9536                "workspace retains a bound session; remove every session before unregistering it",
9537            ));
9538        }
9539        drop(bindings);
9540        let transaction = self
9541            .state_transaction
9542            .lock()
9543            .unwrap_or_else(|poisoned| poisoned.into_inner());
9544        let mut workspaces = self
9545            .workspaces
9546            .write()
9547            .unwrap_or_else(|poisoned| poisoned.into_inner());
9548        if !workspaces.contains_key(workspace_id) {
9549            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9550        }
9551        if workspaces.len() == 1 {
9552            return Err(failure(
9553                NodeFailureCode::LastWorkspace,
9554                "node must retain at least one workspace",
9555            ));
9556        }
9557        let removed_root = workspaces
9558            .remove(workspace_id)
9559            .expect("workspace presence was validated");
9560        drop(workspaces);
9561        let previous_records = {
9562            let mut records = self
9563                .session_records
9564                .lock()
9565                .unwrap_or_else(|poisoned| poisoned.into_inner());
9566            let previous = records.records.clone();
9567            for record in records
9568                .records
9569                .values_mut()
9570                .filter(|record| &record.workspace_id == workspace_id)
9571            {
9572                record.active_session = None;
9573                record.state = ManagedSessionState::Unavailable;
9574                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9575                record.updated_at_unix_ms = unix_time_ms();
9576            }
9577            previous
9578        };
9579        if let Err(error) = self.persist_state_locked() {
9580            self.workspaces
9581                .write()
9582                .unwrap_or_else(|poisoned| poisoned.into_inner())
9583                .insert(workspace_id.clone(), removed_root);
9584            self.session_records
9585                .lock()
9586                .unwrap_or_else(|poisoned| poisoned.into_inner())
9587                .records = previous_records;
9588            return Err(persistence_failure(error));
9589        }
9590        let unavailable = self
9591            .session_records
9592            .lock()
9593            .unwrap_or_else(|poisoned| poisoned.into_inner())
9594            .records
9595            .values()
9596            .filter(|record| &record.workspace_id == workspace_id)
9597            .cloned()
9598            .collect::<Vec<_>>();
9599        drop(transaction);
9600        self.publish(NodeEvent::WorkspaceRemoved {
9601            workspace_id: workspace_id.clone(),
9602        });
9603        for record in unavailable {
9604            self.publish_record(record);
9605        }
9606        Ok(())
9607    }
9608
9609    async fn create_worktree(
9610        &self,
9611        source_workspace_id: WorkspaceId,
9612        workspace_id: WorkspaceId,
9613        target_root: String,
9614        branch: String,
9615        base: Option<String>,
9616    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9617        self.require_worktree_service(&source_workspace_id)?;
9618        validate_workspace_request_root(&target_root)?;
9619        validate_git_revision("worktree base", base.as_deref())?;
9620        let source_root = self.workspace_root(&source_workspace_id)?;
9621        {
9622            let workspaces = self
9623                .workspaces
9624                .read()
9625                .unwrap_or_else(|poisoned| poisoned.into_inner());
9626            if workspaces.contains_key(&workspace_id) {
9627                return Err(failure(
9628                    NodeFailureCode::DuplicateWorkspaceId,
9629                    "workspace ID is already registered",
9630                ));
9631            }
9632            if let Some((existing_id, _)) = workspaces
9633                .iter()
9634                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9635            {
9636                return Err(failure(
9637                    NodeFailureCode::DuplicateWorkspaceRoot,
9638                    &format!("workspace root is already registered as '{existing_id}'"),
9639                ));
9640            }
9641        }
9642        let mut worktree = create_git_worktree(
9643            &source_root,
9644            &target_root,
9645            &branch,
9646            base.as_deref(),
9647        )
9648        .await
9649        .map_err(git_worktree_failure)?;
9650        let workspace = match self
9651            .register_workspace(workspace_id, worktree.path.clone())
9652            .await
9653        {
9654            Ok(workspace) => workspace,
9655            Err(registration_error) => {
9656                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9657                    return Err(failure(
9658                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659                        "worktree registration failed and compensation requires recovery",
9660                    ));
9661                }
9662                return Err(failure(
9663                    registration_error.code,
9664                    &registration_error.message,
9665                ));
9666            }
9667        };
9668        worktree.workspace_id = Some(workspace.workspace_id.clone());
9669        Ok((protocol_worktree(worktree), workspace))
9670    }
9671
9672    async fn remove_worktree(
9673        &self,
9674        source_workspace_id: WorkspaceId,
9675        target_root: String,
9676    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9677        self.require_worktree_service(&source_workspace_id)?;
9678        validate_workspace_request_root(&target_root)?;
9679        let source_root = self.workspace_root(&source_workspace_id)?;
9680        let target_root = normalize_worktree_removal_target(&target_root)
9681            .map_err(git_worktree_failure)?;
9682        let target = list_git_worktrees(&source_root)
9683            .await
9684            .map_err(git_worktree_failure)?
9685            .into_iter()
9686            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9687            .ok_or_else(|| {
9688                failure(
9689                    NodeFailureCode::WorktreeProtected,
9690                    "refusing to remove a path that is not in Git's worktree listing",
9691                )
9692            })?;
9693        let registered_workspace_id = {
9694            let workspaces = self
9695                .workspaces
9696                .read()
9697                .unwrap_or_else(|poisoned| poisoned.into_inner());
9698            let matched = workspaces
9699                .iter()
9700                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9701                .map(|(workspace_id, _)| workspace_id.clone());
9702            if matched.is_some() && workspaces.len() == 1 {
9703                return Err(failure(
9704                    NodeFailureCode::LastWorkspace,
9705                    "node must retain at least one workspace",
9706                ));
9707            }
9708            matched
9709        };
9710        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9711            let bindings = self
9712                .session_bindings
9713                .lock()
9714                .unwrap_or_else(|poisoned| poisoned.into_inner());
9715            if bindings
9716                .values()
9717                .any(|binding| &binding.workspace_id == workspace_id)
9718            {
9719                return Err(failure(
9720                    NodeFailureCode::WorkspaceBusy,
9721                    "worktree workspace retains a bound session; remove every session first",
9722                ));
9723            }
9724        }
9725        remove_git_worktree(&source_root, &target.path)
9726            .await
9727            .map_err(git_worktree_failure)?;
9728        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9729            self.unregister_workspace(workspace_id)
9730                .map_err(|error| failure(error.code, &error.message))?;
9731        }
9732        Ok(registered_workspace_id)
9733    }
9734
9735    async fn cleanup_managed_worktree(
9736        &self,
9737        lease_id: &ManagedWorktreeLeaseId,
9738        explicit: bool,
9739    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9740        let lease = self.managed_worktrees.lock()
9741            .unwrap_or_else(|poisoned| poisoned.into_inner())
9742            .get(lease_id)
9743            .cloned()
9744            .ok_or_else(|| failure(
9745                NodeFailureCode::UnknownManagedWorktreeLease,
9746                "managed worktree lease does not exist",
9747            ))?;
9748        if lease.state == ManagedWorktreeLeaseState::Removed {
9749            return Ok(lease.snapshot());
9750        }
9751        if lease.has_holders() {
9752            return Err(failure(
9753                NodeFailureCode::ManagedWorktreeBusy,
9754                "managed worktree lease still has a session or durable-record holder",
9755            ));
9756        }
9757        if self
9758            .materializations
9759            .lock()
9760            .unwrap_or_else(|poisoned| poisoned.into_inner())
9761            .values()
9762            .any(|record| record.managed_lease_id() == Some(lease_id))
9763        {
9764            return Err(failure(
9765                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9766                "managed worktree cleanup is blocked by session environment ownership",
9767            ));
9768        }
9769        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9770            let snapshot = {
9771                let mut registry = self.managed_worktrees.lock()
9772                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9773                let current = registry.get_mut(lease_id)
9774                    .expect("managed worktree lease remains present");
9775                current.state = ManagedWorktreeLeaseState::Retained;
9776                current.cleanup_failure = None;
9777                current.updated_at_unix_ms = unix_time_ms();
9778                current.snapshot()
9779            };
9780            if self.persist_state().is_err() {
9781                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9782            }
9783            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9784            return Ok(snapshot);
9785        }
9786        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9787        if profile.revision() != &lease.profile_revision {
9788            self.mark_managed_recovery_required(
9789                lease_id,
9790                ManagedWorktreeCleanupFailure::OwnershipConflict,
9791            );
9792            return Err(failure(
9793                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9794                "managed worktree profile revision changed",
9795            ));
9796        }
9797        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9798            self.mark_managed_recovery_required(
9799                lease_id,
9800                ManagedWorktreeCleanupFailure::OwnershipConflict,
9801            );
9802            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9803        })?;
9804        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9805        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9806            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9807            managed_git_worktree_failure(error)
9808        })?;
9809        let target = listed.iter()
9810            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9811        if let Some(target) = target {
9812            if target.is_main || target.is_bare || !exact_owned_worktree(
9813                &lease,
9814                &target.path,
9815                target.branch.as_deref(),
9816                &target.head,
9817            ) {
9818                self.mark_managed_recovery_required(
9819                    lease_id,
9820                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9821                );
9822                return Err(failure(
9823                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9824                    "Git worktree identity no longer matches the managed lease",
9825                ));
9826            }
9827            if target.prunable {
9828                self.mark_managed_cleanup_blocked(
9829                    lease_id,
9830                    ManagedWorktreeCleanupFailure::Prunable,
9831                );
9832                return Err(failure(
9833                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9834                    "managed worktree is prunable and requires recovery",
9835                ));
9836            }
9837            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9838                let failure_kind = match error.kind {
9839                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9840                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9841                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9842                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9843                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9844                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9845                };
9846                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9847                    self.mark_managed_recovery_required(lease_id, failure_kind);
9848                } else {
9849                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9850                }
9851                return Err(managed_git_worktree_failure(error));
9852            }
9853        } else {
9854            match std::fs::symlink_metadata(&lease.target_root) {
9855                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9856                Ok(_) => {
9857                    self.mark_managed_recovery_required(
9858                        lease_id,
9859                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9860                    );
9861                    return Err(failure(
9862                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9863                        "managed target exists but is not owned by Git",
9864                    ));
9865                }
9866                Err(_) => {
9867                    self.mark_managed_cleanup_blocked(
9868                        lease_id,
9869                        ManagedWorktreeCleanupFailure::Backend,
9870                    );
9871                    return Err(failure(
9872                        NodeFailureCode::BackendOperationFailed,
9873                        "managed target identity could not be inspected",
9874                    ));
9875                }
9876            }
9877        }
9878        let registered = self.workspaces.read()
9879            .unwrap_or_else(|poisoned| poisoned.into_inner())
9880            .contains_key(&lease.workspace_id);
9881        if registered {
9882            self.unregister_workspace(&lease.workspace_id)?;
9883        }
9884        let removed = {
9885            self.managed_worktrees.lock()
9886                .unwrap_or_else(|poisoned| poisoned.into_inner())
9887                .tombstone(lease_id, unix_time_ms())
9888                .expect("managed worktree lease remains present during cleanup")
9889        };
9890        self.persist_state().map_err(persistence_failure)?;
9891        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9892        Ok(removed.snapshot())
9893    }
9894
9895    fn mark_managed_cleanup_blocked(
9896        &self,
9897        lease_id: &ManagedWorktreeLeaseId,
9898        failure_kind: ManagedWorktreeCleanupFailure,
9899    ) {
9900        let snapshot = {
9901            let mut registry = self.managed_worktrees.lock()
9902                .unwrap_or_else(|poisoned| poisoned.into_inner());
9903            let Some(lease) = registry.get_mut(lease_id) else { return };
9904            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9905            lease.cleanup_failure = Some(failure_kind);
9906            lease.updated_at_unix_ms = unix_time_ms();
9907            lease.snapshot()
9908        };
9909        let _ = self.persist_state();
9910        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9911    }
9912
9913    fn require_worktree_service(
9914        &self,
9915        source_workspace_id: &WorkspaceId,
9916    ) -> Result<(), NodeFailure> {
9917        if self.managed_worktrees.lock()
9918            .unwrap_or_else(|poisoned| poisoned.into_inner())
9919            .lease_for_workspace(source_workspace_id)
9920            .is_some()
9921        {
9922            return Err(failure(
9923                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9924                "managed worktree targets are internal-only workspaces",
9925            ));
9926        }
9927        match self
9928            .worktree_service_modes
9929            .get(source_workspace_id)
9930            .copied()
9931            .unwrap_or(WorktreeServiceMode::Manual)
9932        {
9933            WorktreeServiceMode::Manual => Ok(()),
9934            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9935                NodeFailureCode::UnsupportedSpawnCapability,
9936                "Git worktree service mode is unavailable in this node build",
9937            )),
9938        }
9939    }
9940
9941    fn managed_reservation_conflict(
9942        &self,
9943        workspace_id: Option<&WorkspaceId>,
9944        root: Option<&str>,
9945    ) -> bool {
9946        self.managed_worktrees.lock()
9947            .unwrap_or_else(|poisoned| poisoned.into_inner())
9948            .active_records()
9949            .any(|lease| {
9950                workspace_id.is_some_and(|workspace_id| {
9951                    workspace_id == &lease.source_workspace_id
9952                        || workspace_id == &lease.workspace_id
9953                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9954            })
9955    }
9956
9957    fn reject_managed_reservation(
9958        &self,
9959        workspace_id: Option<&WorkspaceId>,
9960        root: Option<&str>,
9961    ) -> Result<(), NodeFailure> {
9962        if self.managed_reservation_conflict(workspace_id, root) {
9963            Err(failure(
9964                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9965                "managed worktree ownership may only be mutated through its lease cleanup",
9966            ))
9967        } else {
9968            Ok(())
9969        }
9970    }
9971
9972    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9973        self.validate_address(address)?;
9974        self.workspace_root(&address.workspace_id)
9975    }
9976
9977    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9978    /// `require_policy`'s own doc on those two requirements), so they keep
9979    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9980    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9981    /// `false` on the `require_policy` call below for them because a PTY
9982    /// resume never needs those facts, not because this method cannot look
9983    /// them up.
9984    ///
9985    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9986    /// the SAME `binding.runtime_policy` every other requirement reads here
9987    /// (one computed policy, read by every gate). That policy is no longer
9988    /// PTY-terminal-verification-shaped for an ACP session --
9989    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9990    /// `provider_runtime::policy_for_transport` -- so consulting it for
9991    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9992    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9993    /// even though a prompt is an ACP session's only interface). Pipe keeps
9994    /// reading the same PTY-probe-derived value it always has --
9995    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9996    /// its own doc for why).
9997    ///
9998    /// What this method still adds on top, for ACP/Pipe only, is a
9999    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10000    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10001    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10002    /// read off the same live `SessionSnapshot` state `require_acp_session`
10003    /// already reads for the ACP control verbs -- `TurnStarted` observed
10004    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10005    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10006    /// its fate in vendor-specific behaviour this node cannot observe (one
10007    /// agent may queue it, another drop it, another interleave it), so this
10008    /// refuses it outright rather than gambling on that -- a rule with no
10009    /// PTY analogue, which is why PTY does not take this extra precondition.
10010    fn require_session_runtime_policy(
10011        &self,
10012        address: &SessionAddress,
10013        requirement: ProviderRuntimeRequirement,
10014    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10015        let runtime_policy = {
10016            let bindings = self
10017                .session_bindings
10018                .lock()
10019                .unwrap_or_else(|poisoned| poisoned.into_inner());
10020            let binding = bindings
10021                .get(&address.session.instance_id)
10022                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10023            if binding.workspace_id != address.workspace_id {
10024                return Err(failure(
10025                    NodeFailureCode::SessionWorkspaceMismatch,
10026                    "session belongs to another workspace",
10027                ));
10028            }
10029            if binding.generation != address.session.generation {
10030                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10031            }
10032            binding.runtime_policy
10033        };
10034        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10035            let snapshot = self.internal_session_snapshot(address)?;
10036            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10037                && snapshot.provider.lead_activity != ProviderActivity::Idle
10038            {
10039                return Err(failure(
10040                    NodeFailureCode::TurnInFlight,
10041                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10042                ));
10043            }
10044        }
10045        require_policy(runtime_policy, false, false, requirement)
10046            .map(|()| runtime_policy)
10047            .map_err(|_| failure(
10048                NodeFailureCode::UnsupportedCapability,
10049                "session runtime policy does not admit this semantic operation",
10050            ))
10051    }
10052
10053    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10054        let mut bindings = self
10055            .session_bindings
10056            .lock()
10057            .unwrap_or_else(|poisoned| poisoned.into_inner());
10058        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10059            binding.workspace_id == address.workspace_id
10060                && binding.generation == address.session.generation
10061        }) {
10062            let removed = bindings.remove(&address.session.instance_id);
10063            self.clear_terminal_frame_watermark(address);
10064            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10065            return removed;
10066        }
10067        None
10068    }
10069
10070    fn cleanup_session_owned_materialization(
10071        &self,
10072        address: &SessionAddress,
10073        binding: &SessionBinding,
10074    ) -> Result<(), NodeFailure> {
10075        let Some(id) = binding.materialization_id.as_ref() else {
10076            return Ok(());
10077        };
10078        let owner = self
10079            .materializations
10080            .lock()
10081            .unwrap_or_else(|poisoned| poisoned.into_inner())
10082            .get(id)
10083            .map(|record| record.owner().clone());
10084        match owner {
10085            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10086            Some(MaterializationOwner::Session {
10087                incarnation_id,
10088                instance_id,
10089                generation,
10090            }) if incarnation_id == self.incarnation_id
10091                && instance_id == address.session.instance_id
10092                && generation == address.session.generation =>
10093            {
10094                self.cleanup_materialization(id)
10095            }
10096            Some(MaterializationOwner::Session { .. }) => Err(failure(
10097                NodeFailureCode::BackendOperationFailed,
10098                "session environment ownership requires recovery",
10099            )),
10100        }
10101    }
10102
10103    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10104        let mut watermarks = self
10105            .terminal_frame_watermarks
10106            .lock()
10107            .unwrap_or_else(|poisoned| poisoned.into_inner());
10108        if watermarks
10109            .get(&address.session.instance_id)
10110            .is_some_and(|(current, _)| current == address)
10111        {
10112            watermarks.remove(&address.session.instance_id);
10113        }
10114    }
10115
10116    #[cfg(test)]
10117    fn address_for(
10118        &self,
10119        instance_id: AgentInstanceId,
10120        generation: SessionGeneration,
10121    ) -> Option<SessionAddress> {
10122        let binding = self.session_bindings
10123            .lock()
10124            .unwrap_or_else(|poisoned| poisoned.into_inner())
10125            .get(&instance_id)
10126            .cloned()?;
10127        (binding.generation == generation).then_some(SessionAddress {
10128            workspace_id: binding.workspace_id,
10129            session: SessionKey { instance_id, generation },
10130        })
10131    }
10132
10133    fn arm_resume(
10134        &self,
10135        address: &SessionAddress,
10136        command_id: CommandId,
10137        runtime_policy: ProviderRuntimePolicy,
10138    ) -> Result<(), NodeFailure> {
10139        let mut bindings = self
10140            .session_bindings
10141            .lock()
10142            .unwrap_or_else(|poisoned| poisoned.into_inner());
10143        let binding = bindings
10144            .get_mut(&address.session.instance_id)
10145            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10146        if binding.workspace_id != address.workspace_id {
10147            return Err(failure(
10148                NodeFailureCode::SessionWorkspaceMismatch,
10149                "session belongs to another workspace",
10150            ));
10151        }
10152        if binding.generation != address.session.generation {
10153            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10154        }
10155        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10156        Ok(())
10157    }
10158
10159    fn clear_armed_resume(&self, address: &SessionAddress) {
10160        let mut bindings = self
10161            .session_bindings
10162            .lock()
10163            .unwrap_or_else(|poisoned| poisoned.into_inner());
10164        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10165            if binding.workspace_id == address.workspace_id
10166                && binding.generation == address.session.generation
10167            {
10168                binding.pending_resume = None;
10169            }
10170        }
10171    }
10172
10173    fn publish_control(&self, event: ControlEvent) {
10174        let revokes_harness_mcp = matches!(
10175            &event.event,
10176            ControlEventKind::Exited { .. }
10177                | ControlEventKind::Failed { .. }
10178                | ControlEventKind::Removed
10179        );
10180        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10181            let mut bindings = self
10182                .session_bindings
10183                .lock()
10184                .unwrap_or_else(|poisoned| poisoned.into_inner());
10185            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10186                return;
10187            };
10188            let previous_address = SessionAddress {
10189                workspace_id: binding.workspace_id.clone(),
10190                session: SessionKey {
10191                    instance_id: event.instance_id,
10192                    generation: binding.generation,
10193                },
10194            };
10195            let mut resume_command_rejected = false;
10196            if binding.generation == event.generation {
10197                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10198                    let rejected_resume = matches!(
10199                        &event.event,
10200                        ControlEventKind::CommandRejected { .. }
10201                    ) && event.command_id == Some(pending_command_id);
10202                    let resume_failed = matches!(
10203                        &event.event,
10204                        ControlEventKind::ResumeDenied { .. }
10205                            | ControlEventKind::ResumeFailed { .. }
10206                    );
10207                    if pending_generation == binding.generation
10208                        && (rejected_resume || resume_failed)
10209                    {
10210                        resume_command_rejected = rejected_resume;
10211                        binding.pending_resume = None;
10212                    }
10213                }
10214            } else {
10215                let expected = binding.generation.0.checked_add(1);
10216                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10217                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10218                    generation == binding.generation
10219                })
10220                    || expected != Some(event.generation.0)
10221                    || !authorized
10222                {
10223                    return;
10224                }
10225                let (_, _, runtime_policy) = binding
10226                    .pending_resume
10227                    .expect("authorized resume retains its pending runtime policy");
10228                binding.generation = event.generation;
10229                binding.runtime_policy = runtime_policy;
10230                binding.pending_resume = None;
10231            }
10232            (
10233                SessionAddress {
10234                    workspace_id: binding.workspace_id.clone(),
10235                    session: SessionKey {
10236                        instance_id: event.instance_id,
10237                        generation: event.generation,
10238                    },
10239                },
10240                (previous_address.session.generation != event.generation)
10241                    .then_some(previous_address),
10242                binding.record_id.clone(),
10243                binding.runtime_policy.provider_session_identity,
10244                resume_command_rejected,
10245            )
10246        };
10247        if let Some(previous_address) = previous_address.as_ref() {
10248            self.clear_terminal_frame_watermark(previous_address);
10249        }
10250        if let Some(record_id) = record_id {
10251            self.reconcile_managed_record(
10252                &record_id,
10253                &address,
10254                &event,
10255                identity_admitted,
10256                resume_command_rejected,
10257            );
10258        }
10259        // Every state change a session's process lifecycle can reach gets a
10260        // line here: this is the single chokepoint every `ControlEventKind`
10261        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10262        // came from a fresh spawn or a resume. Per-message provider chatter
10263        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10264        // matched here -- that is exactly the per-frame noise this crate
10265        // family's level discipline forbids at INFO.
10266        match &event.event {
10267            ControlEventKind::Running { process_id } => {
10268                tracing::info!(
10269                    node_id = %self.node_id,
10270                    workspace_id = %address.workspace_id,
10271                    session = ?address.session,
10272                    pid = ?process_id,
10273                    "session process is running",
10274                );
10275            }
10276            ControlEventKind::Exited { exit_code, forced } => {
10277                tracing::info!(
10278                    node_id = %self.node_id,
10279                    workspace_id = %address.workspace_id,
10280                    session = ?address.session,
10281                    exit_code = ?exit_code,
10282                    forced = forced,
10283                    "session process exited",
10284                );
10285            }
10286            ControlEventKind::Failed { message } => {
10287                tracing::warn!(
10288                    node_id = %self.node_id,
10289                    workspace_id = %address.workspace_id,
10290                    session = ?address.session,
10291                    cause = %message,
10292                    "session failed",
10293                );
10294            }
10295            ControlEventKind::CommandRejected { message } => {
10296                tracing::warn!(
10297                    node_id = %self.node_id,
10298                    workspace_id = %address.workspace_id,
10299                    session = ?address.session,
10300                    cause = %message,
10301                    "session command rejected",
10302                );
10303            }
10304            ControlEventKind::Removed => {
10305                tracing::info!(
10306                    node_id = %self.node_id,
10307                    workspace_id = %address.workspace_id,
10308                    session = ?address.session,
10309                    "session removed",
10310                );
10311            }
10312            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10313                tracing::info!(
10314                    node_id = %self.node_id,
10315                    workspace_id = %address.workspace_id,
10316                    session = ?address.session,
10317                    provider_session = ?provider_session,
10318                    "session resume authorized",
10319                );
10320            }
10321            ControlEventKind::Resumed { session: provider_session, process_id } => {
10322                tracing::info!(
10323                    node_id = %self.node_id,
10324                    workspace_id = %address.workspace_id,
10325                    session = ?address.session,
10326                    provider_session = ?provider_session,
10327                    pid = ?process_id,
10328                    "session resumed",
10329                );
10330            }
10331            ControlEventKind::ResumeDenied { reason } => {
10332                tracing::warn!(
10333                    node_id = %self.node_id,
10334                    workspace_id = %address.workspace_id,
10335                    session = ?address.session,
10336                    cause = %reason,
10337                    "session resume denied",
10338                );
10339            }
10340            ControlEventKind::ResumeFailed { message } => {
10341                tracing::warn!(
10342                    node_id = %self.node_id,
10343                    workspace_id = %address.workspace_id,
10344                    session = ?address.session,
10345                    cause = %message,
10346                    "session resume failed",
10347                );
10348            }
10349            _ => {}
10350        }
10351        let stream_chunk = agent_stream_chunk(&event);
10352        self.publish(NodeEvent::Control {
10353            address: address.clone(),
10354            event,
10355        });
10356        if let Some(chunk) = stream_chunk {
10357            self.publish_agent_stream_chunk(address.clone(), chunk);
10358        }
10359        if revokes_harness_mcp {
10360            SessionLifecycleHook::on_session_ended(self, &address);
10361        }
10362    }
10363
10364    fn reconcile_managed_record(
10365        &self,
10366        record_id: &SessionRecordId,
10367        address: &SessionAddress,
10368        event: &ControlEvent,
10369        identity_admitted: bool,
10370        resume_command_rejected: bool,
10371    ) {
10372        let _transaction = self
10373            .state_transaction
10374            .lock()
10375            .unwrap_or_else(|poisoned| poisoned.into_inner());
10376        let observed_identity = match &event.event {
10377            ControlEventKind::ProviderEvent {
10378                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10379                ..
10380            } if identity_admitted => Some(identity.clone()),
10381            _ => None,
10382        };
10383        let identity_was_observed = observed_identity.is_some();
10384        let replacement_id = observed_identity
10385            .as_ref()
10386            .and_then(|_| self.allocate_record_id().ok());
10387        let mut upserts = Vec::new();
10388        let mut removals = Vec::new();
10389        let mut rebind = None;
10390        let original_records;
10391        let original_materializations = self
10392            .materializations
10393            .lock()
10394            .unwrap_or_else(|poisoned| poisoned.into_inner())
10395            .clone();
10396        {
10397            let mut records = self
10398                .session_records
10399                .lock()
10400                .unwrap_or_else(|poisoned| poisoned.into_inner());
10401            original_records = records.records.clone();
10402            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10403                return;
10404            };
10405            if let Some(identity) = observed_identity {
10406                let existing_id = records
10407                    .records
10408                    .values()
10409                    .find(|record| {
10410                        record.record_id != *record_id
10411                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10412                                session_registry::same_provider_session(
10413                                    &record.provider,
10414                                    record_identity,
10415                                    &current_snapshot.provider,
10416                                    &identity,
10417                                )
10418                            })
10419                    })
10420                    .map(|record| record.record_id.clone());
10421                if let Some(existing_id) = existing_id {
10422                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10423                        record.mode == current_snapshot.mode
10424                            && record.workspace_id == current_snapshot.workspace_id
10425                            && record.environment_profile == current_snapshot.environment_profile
10426                            && record.bundle == current_snapshot.bundle
10427                            && record.context_id == current_snapshot.context_id
10428                            && record.context == current_snapshot.context
10429                            && record
10430                                .canonical_root
10431                                .as_utf8()
10432                                .zip(current_snapshot.canonical_root.as_utf8())
10433                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10434                    });
10435                    if !scope_matches {
10436                        if let Some(current) = records.records.get_mut(record_id) {
10437                            current.state = ManagedSessionState::Unavailable;
10438                            current.last_error =
10439                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10440                            current.updated_at_unix_ms = unix_time_ms();
10441                            upserts.push(current.clone());
10442                        }
10443                    } else {
10444                        let existing_is_active = records
10445                            .records
10446                            .get(&existing_id)
10447                            .is_some_and(|record| record.active_session.is_some());
10448                        if existing_is_active {
10449                            if let Some(current) = records.records.get_mut(record_id) {
10450                                current.state = ManagedSessionState::Unavailable;
10451                                current.last_error =
10452                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10453                                current.updated_at_unix_ms = unix_time_ms();
10454                                upserts.push(current.clone());
10455                            }
10456                        } else {
10457                            if current_snapshot.provider_session.is_some() {
10458                                if let Some(current) = records.records.get_mut(record_id) {
10459                                    current.active_session = None;
10460                                    current.state = ManagedSessionState::Dormant;
10461                                    current.updated_at_unix_ms = unix_time_ms();
10462                                    upserts.push(current.clone());
10463                                }
10464                            } else {
10465                                records.records.remove(record_id);
10466                                removals.push(record_id.clone());
10467                            }
10468                            if let Some(existing) = records.records.get_mut(&existing_id) {
10469                                existing.provider_session = Some(identity.clone());
10470                                existing.active_session = Some(address.clone());
10471                                existing.state = ManagedSessionState::Live;
10472                                existing.updated_at_unix_ms = unix_time_ms();
10473                                existing.last_error = None;
10474                                upserts.push(existing.clone());
10475                            }
10476                            rebind = Some(existing_id);
10477                        }
10478                    }
10479                } else if current_snapshot
10480                    .provider_session
10481                    .as_ref()
10482                    .is_some_and(|current| {
10483                        !session_registry::same_provider_session(
10484                            &current_snapshot.provider,
10485                            current,
10486                            &current_snapshot.provider,
10487                            &identity,
10488                        )
10489                    })
10490                {
10491                    if let Some(current) = records.records.get_mut(record_id) {
10492                        current.active_session = None;
10493                        if current.bundle.is_some() || current.context.is_some() {
10494                            let had_bundle = current.bundle.is_some();
10495                            current.provider_session = None;
10496                            current.bundle = None;
10497                            current.context_id = None;
10498                            current.context = None;
10499                            current.state = ManagedSessionState::Unavailable;
10500                            current.last_error = Some(if had_bundle {
10501                                "bundle-unavailable".to_owned()
10502                            } else {
10503                                "context-unavailable".to_owned()
10504                            });
10505                        } else {
10506                            current.state = ManagedSessionState::Dormant;
10507                        }
10508                        current.updated_at_unix_ms = unix_time_ms();
10509                        upserts.push(current.clone());
10510                    }
10511                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10512                        if let Some(current) = records.records.get_mut(record_id) {
10513                            current.state = ManagedSessionState::Unavailable;
10514                            current.last_error =
10515                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10516                            current.updated_at_unix_ms = unix_time_ms();
10517                            upserts.push(current.clone());
10518                        }
10519                    } else if let Some(new_record_id) = replacement_id {
10520                        let now = unix_time_ms();
10521                        let replacement = ManagedSessionRecord {
10522                            record_id: new_record_id.clone(),
10523                            display_name: format!(
10524                                "{} #{}",
10525                                current_snapshot.provider.as_str(),
10526                                records
10527                                    .records
10528                                    .values()
10529                                    .filter(|record| {
10530                                        record.provider == current_snapshot.provider
10531                                    })
10532                                    .count()
10533                                    .saturating_add(1),
10534                            ),
10535                            provider: current_snapshot.provider.clone(),
10536                            mode: current_snapshot.mode,
10537                            state: ManagedSessionState::Live,
10538                            workspace_id: current_snapshot.workspace_id,
10539                            canonical_root: current_snapshot.canonical_root,
10540                            provider_session: Some(identity),
10541                            active_session: Some(address.clone()),
10542                            environment_profile: current_snapshot.environment_profile,
10543                            bundle: current_snapshot.bundle,
10544                            context_id: current_snapshot.context_id,
10545                            context: current_snapshot.context,
10546                            // This is a fresh record_id representing the session under its
10547                            // newly-observed identity; it has not yet produced any clean
10548                            // exit of its own, so it starts with no exported pack (the old
10549                            // record, still addressable under its own record_id, keeps
10550                            // whatever exported_context it already had).
10551                            exported_context: None,
10552                            task_binding: current_snapshot.task_binding,
10553                            created_at_unix_ms: now,
10554                            updated_at_unix_ms: now,
10555                            last_error: None,
10556                        };
10557                        records
10558                            .records
10559                            .insert(new_record_id.clone(), replacement.clone());
10560                        upserts.push(replacement);
10561                        rebind = Some(new_record_id);
10562                    } else if let Some(current) = records.records.get_mut(record_id) {
10563                        current.state = ManagedSessionState::Unavailable;
10564                        current.last_error =
10565                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10566                        upserts.push(current.clone());
10567                    }
10568                } else if let Some(current) = records.records.get_mut(record_id) {
10569                    current.provider_session = Some(identity);
10570                    current.active_session = Some(address.clone());
10571                    current.state = ManagedSessionState::Live;
10572                    current.updated_at_unix_ms = unix_time_ms();
10573                    current.last_error = None;
10574                    upserts.push(current.clone());
10575                }
10576            } else if let Some(current) = records.records.get_mut(record_id) {
10577                if resume_command_rejected {
10578                    current.active_session = None;
10579                    current.state = detached_record_state(current);
10580                    current.updated_at_unix_ms = unix_time_ms();
10581                    current.last_error =
10582                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10583                    upserts.push(current.clone());
10584                } else {
10585                    match &event.event {
10586                    ControlEventKind::Running { .. }
10587                    | ControlEventKind::ResumeAuthorized { .. }
10588                    | ControlEventKind::Resumed { .. } => {
10589                        current.active_session = Some(address.clone());
10590                        current.state = active_record_state(
10591                            identity_admitted,
10592                            current.provider_session.is_some(),
10593                        );
10594                        current.updated_at_unix_ms = unix_time_ms();
10595                        current.last_error = None;
10596                        upserts.push(current.clone());
10597                    }
10598                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10599                        current.active_session = None;
10600                        current.state = detached_record_state(current);
10601                        current.updated_at_unix_ms = unix_time_ms();
10602                        upserts.push(current.clone());
10603                    }
10604                    ControlEventKind::Failed { message }
10605                    | ControlEventKind::ResumeFailed { message } => {
10606                        current.active_session = None;
10607                        current.state = detached_record_state(current);
10608                        current.updated_at_unix_ms = unix_time_ms();
10609                        current.last_error = Some(
10610                            session_registry::sanitized_record_error_summary(message),
10611                        );
10612                        upserts.push(current.clone());
10613                    }
10614                    ControlEventKind::ResumeDenied { reason } => {
10615                        current.active_session = None;
10616                        current.state = detached_record_state(current);
10617                        current.updated_at_unix_ms = unix_time_ms();
10618                        current.last_error = Some(
10619                            session_registry::sanitized_record_error_summary(reason),
10620                        );
10621                        upserts.push(current.clone());
10622                    }
10623                        _ => {}
10624                    }
10625                }
10626            }
10627        }
10628        let materialization_owner_record = identity_was_observed.then(|| {
10629            rebind.clone().unwrap_or_else(|| record_id.clone())
10630        }).and_then(|candidate| {
10631            self.session_records
10632                .lock()
10633                .unwrap_or_else(|poisoned| poisoned.into_inner())
10634                .records
10635                .get(&candidate)
10636                .filter(|record| {
10637                    record.provider_session.is_some()
10638                        && record.active_session.as_ref() == Some(address)
10639                })
10640                .map(|_| candidate)
10641        });
10642        if let Some(owner_record_id) = materialization_owner_record {
10643            let materialization_id = self
10644                .session_bindings
10645                .lock()
10646                .unwrap_or_else(|poisoned| poisoned.into_inner())
10647                .get(&address.session.instance_id)
10648                .and_then(|binding| binding.materialization_id.clone());
10649            if let Some(materialization_id) = materialization_id {
10650                let mut transfer_failed = false;
10651                {
10652                    let mut materializations = self
10653                        .materializations
10654                        .lock()
10655                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10656                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10657                        match ownership.owner() {
10658                            MaterializationOwner::Session {
10659                                incarnation_id,
10660                                instance_id,
10661                                generation,
10662                            } if *incarnation_id == self.incarnation_id
10663                                && *instance_id == address.session.instance_id
10664                                && *generation == address.session.generation =>
10665                            {
10666                                transfer_failed = ownership
10667                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10668                                    .is_err();
10669                            }
10670                            MaterializationOwner::Record { record_id }
10671                                if record_id == &owner_record_id => {}
10672                            MaterializationOwner::Record { record_id } => {
10673                                let expected = record_id.clone();
10674                                transfer_failed = ownership
10675                                    .transfer_record_owner(
10676                                        &expected,
10677                                        owner_record_id.clone(),
10678                                        unix_time_ms(),
10679                                    )
10680                                    .is_err();
10681                            }
10682                            _ => transfer_failed = true,
10683                        }
10684                        if transfer_failed {
10685                            let _ = ownership.mark_recovery_required(unix_time_ms());
10686                        }
10687                    } else {
10688                        transfer_failed = true;
10689                    }
10690                }
10691                if transfer_failed {
10692                    if let Some(record) = self
10693                        .session_records
10694                        .lock()
10695                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10696                        .records
10697                        .get_mut(&owner_record_id)
10698                    {
10699                        record.state = ManagedSessionState::Unavailable;
10700                        record.last_error = Some("environment-profile-unavailable".to_owned());
10701                        record.updated_at_unix_ms = unix_time_ms();
10702                        upserts.push(record.clone());
10703                    }
10704                }
10705            }
10706        }
10707        if let Some(new_record_id) = rebind.clone() {
10708            if let Some(binding) = self
10709                .session_bindings
10710                .lock()
10711                .unwrap_or_else(|poisoned| poisoned.into_inner())
10712                .get_mut(&address.session.instance_id)
10713            {
10714                binding.record_id = Some(new_record_id);
10715            }
10716        }
10717        if upserts.is_empty() && removals.is_empty() {
10718            return;
10719        }
10720        if self.persist_state_locked().is_err() {
10721            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10722            let mut records = self
10723                .session_records
10724                .lock()
10725                .unwrap_or_else(|poisoned| poisoned.into_inner());
10726            records.records = original_records;
10727            *self
10728                .materializations
10729                .lock()
10730                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10731            if let Some(current) = records.records.get_mut(record_id) {
10732                current.state = ManagedSessionState::Unavailable;
10733                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10734                current.updated_at_unix_ms = unix_time_ms();
10735                upserts = vec![current.clone()];
10736            } else {
10737                upserts.clear();
10738            }
10739            removals.clear();
10740            drop(records);
10741            if let Some(binding) = self
10742                .session_bindings
10743                .lock()
10744                .unwrap_or_else(|poisoned| poisoned.into_inner())
10745                .get_mut(&address.session.instance_id)
10746            {
10747                binding.record_id = Some(record_id.clone());
10748            }
10749            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10750        }
10751        for record in upserts {
10752            self.publish_record(record);
10753        }
10754        for record_id in removals {
10755            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10756        }
10757    }
10758
10759    fn snapshot(&self) -> NodeSnapshot {
10760        let control = self.handle.snapshot();
10761        let bindings = self
10762            .session_bindings
10763            .lock()
10764            .unwrap_or_else(|poisoned| poisoned.into_inner())
10765            .clone();
10766        let workspace_roots = self
10767            .workspaces
10768            .read()
10769            .unwrap_or_else(|poisoned| poisoned.into_inner())
10770            .clone();
10771        let environment_profiles = self
10772            .environment_profiles
10773            .read()
10774            .unwrap_or_else(|poisoned| poisoned.into_inner());
10775        let mut workspaces = workspace_roots
10776            .iter()
10777            .map(|(workspace_id, canonical_root)| {
10778                (
10779                    workspace_id.clone(),
10780                    WorkspaceSnapshot {
10781                        workspace_id: workspace_id.clone(),
10782                        canonical_root: opaque_windows_path(canonical_root.clone()),
10783                        sessions: Vec::new(),
10784                        worktree_service_mode: Some(
10785                            self.worktree_service_modes
10786                                .get(workspace_id)
10787                                .copied()
10788                                .unwrap_or(WorktreeServiceMode::Manual),
10789                        ),
10790                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10791                            profiles: self
10792                                .managed_worktree_profiles
10793                                .get(workspace_id)
10794                                .into_iter()
10795                                .flat_map(BTreeMap::values)
10796                                .map(|profile| ManagedWorktreeProfileSummary {
10797                                    id: profile.profile_id().clone(),
10798                                    revision: profile.revision().clone(),
10799                                    retention: profile.retention(),
10800                                })
10801                                .collect(),
10802                        }),
10803                    },
10804                )
10805            })
10806            .collect::<BTreeMap<_, _>>();
10807        let mut agent_progress = Vec::new();
10808        for session in &control.sessions {
10809            let Some(binding) = bindings.get(&session.instance_id) else {
10810                continue;
10811            };
10812            if binding.generation != session.generation {
10813                continue;
10814            }
10815            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10816                let address = SessionAddress {
10817                    workspace_id: binding.workspace_id.clone(),
10818                    session: SessionKey {
10819                        instance_id: session.instance_id,
10820                        generation: session.generation,
10821                    },
10822                };
10823                if let Some(progress) =
10824                    agent_progress_from_provider_snapshot(address, &session.provider)
10825                {
10826                    agent_progress.push(progress);
10827                }
10828                workspace.sessions.push(session.clone());
10829            }
10830        }
10831        NodeSnapshot {
10832            node_id: self.node_id.clone(),
10833            enabled_providers: self.enabled_providers.clone(),
10834            provider_runtime_statuses: {
10835                let updates = self
10836                    .provider_runtime_status_updates
10837                    .read()
10838                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10839                ProviderRuntimeStatuses::new(
10840                    self.provider_runtime_statuses
10841                        .iter()
10842                        .filter(|status| !updates.contains_key(status.provider()))
10843                        .cloned()
10844                        .chain(updates.values().cloned()),
10845                )
10846                .expect("refreshed provider runtime inventory remains bounded and unique")
10847            },
10848            workspaces: workspaces.into_values().collect(),
10849            session_records: self
10850                .session_records
10851                .lock()
10852                .unwrap_or_else(|poisoned| poisoned.into_inner())
10853                .records
10854                .values()
10855                .cloned()
10856                .collect(),
10857            managed_worktrees: self.managed_worktrees
10858                .lock()
10859                .unwrap_or_else(|poisoned| poisoned.into_inner())
10860                .snapshots(),
10861            launch_inventory: Some(LaunchInventory {
10862                spawn_profiles: Some(
10863                    self.spawn_profiles
10864                        .iter()
10865                        .filter_map(|profile| {
10866                            resolved_spawn_profile_summary(profile, &environment_profiles)
10867                        })
10868                        .collect(),
10869                ),
10870                bundles: Some(
10871                    self.bundle_catalog
10872                        .read()
10873                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10874                        .iter()
10875                        .map(NodeBundle::receipt)
10876                        .collect(),
10877                ),
10878                // Opaque station allowlist ids from empty-default catalog /
10879                // optional startup loader. Never secrets. Dig2 probe stays stub.
10880                network_allowlists: {
10881                    let catalog = self
10882                        .network_allowlist_catalog
10883                        .read()
10884                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10885                    if catalog.is_empty() {
10886                        None
10887                    } else {
10888                        Some(catalog.ids().cloned().collect())
10889                    }
10890                },
10891            }),
10892            agent_progress,
10893        }
10894    }
10895
10896    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10897        assert!(
10898            !matches!(
10899                &event,
10900                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10901            ),
10902            "terminal frame and agent stream events must use their own dedicated live channel",
10903        );
10904        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10905        let sequence = history
10906            .last_sequence
10907            .checked_add(1)
10908            .expect("node event sequence exhausted");
10909        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10910            if let Some(evicted) = history.events.pop_front() {
10911                history.replay_floor_sequence = evicted
10912                    .sequence
10913                    .checked_add(1)
10914                    .expect("node replay floor sequence exhausted");
10915                history.removed_record_providers.remove(&evicted.sequence);
10916            }
10917        }
10918        match &event {
10919            NodeEvent::HarnessMcpReadCall { .. } => {
10920                panic!("transient harness MCP calls must not enter durable event history")
10921            }
10922            NodeEvent::SessionRecordUpserted { record } => {
10923                history
10924                    .record_providers
10925                    .insert(record.record_id.clone(), record.provider.clone());
10926            }
10927            NodeEvent::SessionRecordRemoved { record_id } => {
10928                if let Some(provider) = history.record_providers.remove(record_id) {
10929                    history.removed_record_providers.insert(sequence, provider);
10930                }
10931            }
10932            NodeEvent::ManagedWorktreeUpserted { .. }
10933            | NodeEvent::ManagedWorktreeRemoved { .. }
10934            | NodeEvent::Control { .. }
10935            | NodeEvent::ControllerChanged { .. }
10936            | NodeEvent::SessionRecordHistorySummarized { .. }
10937            | NodeEvent::WorkspaceAdded { .. }
10938            | NodeEvent::WorkspaceRemoved { .. }
10939            | NodeEvent::ResyncRequired { .. }
10940            | NodeEvent::AgentStream { .. }
10941            | NodeEvent::TerminalFrame { .. } => {}
10942        }
10943        let envelope = NodeEventEnvelope { sequence, event };
10944        history.events.push_back(envelope.clone());
10945        history.last_sequence = sequence;
10946        let _ = self.event_tx.send(envelope.clone());
10947        drop(history);
10948        envelope
10949    }
10950
10951    /// Publishes a transient (never durable) `NodeEvent` -- today only
10952    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10953    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10954    /// why this must never ride the shared `event_tx` bus). Returns whether
10955    /// the send reached at least one subscriber: `broadcast::Sender::send`
10956    /// fails in exactly one way, no active receiver at that instant, and
10957    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10958    /// return to fail the pending call immediately rather than leave it to
10959    /// expire silently at `LOCAL_READ_TIMEOUT`/
10960    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10961    fn publish_transient(&self, event: NodeEvent) -> bool {
10962        assert!(event.requires_harness_mcp_proxy_capability());
10963        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10964        let envelope = NodeEventEnvelope { sequence: 0, event };
10965        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10966            Ok(_) => true,
10967            Err(broadcast::error::SendError(undelivered)) => {
10968                if let Some(NodeEventEnvelope {
10969                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10970                    ..
10971                }) = undelivered.first() {
10972                    // `request.content_type` only, never a per-tool label --
10973                    // this crate does not decode `request.body` and does not
10974                    // hold `hatchery-harness-api` (Nested Control Plane
10975                    // doctrine, Law 3; see this crate's dependency on
10976                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10977                    tracing::warn!(
10978                        call_id = call_id.as_str(),
10979                        content_type = ?request.content_type,
10980                        cause = "no-subscribers",
10981                        "harness MCP proxy event could not be published",
10982                    );
10983                }
10984                false
10985            }
10986        }
10987    }
10988
10989    fn publish_terminal_frames(&self) {
10990        let snapshot = self.handle.snapshot();
10991        let bindings = self
10992            .session_bindings
10993            .lock()
10994            .unwrap_or_else(|poisoned| poisoned.into_inner());
10995        let candidates = snapshot
10996            .sessions
10997            .iter()
10998            .filter_map(|session| {
10999                let binding = bindings.get(&session.instance_id)?;
11000                if binding.generation != session.generation {
11001                    return None;
11002                }
11003                let frame = session.terminal_frame.clone()?;
11004                crate::session_signals::feed(
11005                    binding.workspace_id.as_str(),
11006                    session.instance_id.0,
11007                    session.agent_id.as_str(),
11008                    &frame.contents,
11009                );
11010                Some((
11011                    SessionAddress {
11012                        workspace_id: binding.workspace_id.clone(),
11013                        session: SessionKey {
11014                            instance_id: session.instance_id,
11015                            generation: session.generation,
11016                        },
11017                    },
11018                    frame,
11019                ))
11020            })
11021            .collect();
11022        drop(bindings);
11023        self.publish_terminal_frame_candidates(candidates);
11024    }
11025
11026    fn publish_terminal_frame_candidates(
11027        &self,
11028        candidates: Vec<(SessionAddress, TerminalFrame)>,
11029    ) {
11030        let mut watermarks = self
11031            .terminal_frame_watermarks
11032            .lock()
11033            .unwrap_or_else(|poisoned| poisoned.into_inner());
11034        let mut events = Vec::with_capacity(candidates.len());
11035        for (address, frame) in candidates {
11036            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11037            let advanced = watermarks
11038                .get(&address.session.instance_id)
11039                .map_or(true, |(current_address, sequence)| {
11040                    current_address != &address || frame.sequence > *sequence
11041                });
11042            if !advanced {
11043                continue;
11044            }
11045            if is_first_frame {
11046                // Do NOT sessionize on first PTY output: a session is a
11047                // provider conversation id, not bytes on the wire. Drafts
11048                // leave the provisional map only when HQ attaches
11049                // (Resize / TerminalBytes). The attach-never-arrived timer
11050                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11051                // disconnect grace starts only on a real client WS close.
11052                // This is the first PTY output this node has observed for
11053                // this session -- a one-time event per spawn, not per-frame
11054                // chatter (the watermark makes sure of that). A session that
11055                // reaches this line with no controller attached anywhere on
11056                // the node is running unobserved: exactly how processes from
11057                // repeated spawn attempts went orphaned for hours without a
11058                // single line about it.
11059                if self.controller_state().is_none() {
11060                    tracing::warn!(
11061                        node_id = %self.node_id,
11062                        workspace_id = %address.workspace_id,
11063                        session = ?address.session,
11064                        terminal_size = ?frame.size,
11065                        "session producing output with no controller attached",
11066                    );
11067                } else {
11068                    tracing::info!(
11069                        node_id = %self.node_id,
11070                        workspace_id = %address.workspace_id,
11071                        session = ?address.session,
11072                        terminal_size = ?frame.size,
11073                        "session produced its first output",
11074                    );
11075                }
11076            }
11077            watermarks.insert(
11078                address.session.instance_id,
11079                (address.clone(), frame.sequence),
11080            );
11081            if let Some((at, reported)) = self
11082                .pty_input_marks
11083                .lock()
11084                .unwrap_or_else(|poisoned| poisoned.into_inner())
11085                .get_mut(&address.session.instance_id)
11086            {
11087                // First frame after an input: PTY write -> output published.
11088                if !*reported && at.elapsed() < Duration::from_secs(5) {
11089                    *reported = true;
11090                    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");
11091                }
11092            }
11093            events.push(NodeEvent::TerminalFrame { address, frame });
11094        }
11095        drop(watermarks);
11096        if events.is_empty() {
11097            return;
11098        }
11099        let mut history = self
11100            .history
11101            .lock()
11102            .unwrap_or_else(|poisoned| poisoned.into_inner());
11103        let mut envelopes = Vec::with_capacity(events.len());
11104        for event in events {
11105            let sequence = history
11106                .last_sequence
11107                .checked_add(1)
11108                .expect("node event sequence exhausted");
11109            history.last_sequence = sequence;
11110            envelopes.push(NodeEventEnvelope { sequence, event });
11111        }
11112        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11113        drop(history);
11114    }
11115
11116    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11117    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11118    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11119    /// counter every other event kind shares, never a counter of its own --
11120    /// `queue_connection_event`/`queue_connection_event_batch` compare
11121    /// `envelope.sequence` against one `discard_events_through` watermark
11122    /// regardless of which broadcast channel it arrived on, so a
11123    /// per-channel counter would desynchronize that comparison.
11124    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11125        let mut history = self
11126            .history
11127            .lock()
11128            .unwrap_or_else(|poisoned| poisoned.into_inner());
11129        let sequence = history
11130            .last_sequence
11131            .checked_add(1)
11132            .expect("node event sequence exhausted");
11133        history.last_sequence = sequence;
11134        drop(history);
11135        // Debug, not info/warn -- this fires on every single chunk, but it
11136        // is exactly the counter the next drop-measurement pass needs: what
11137        // the node actually emitted (`source_sequence`, `kind`) versus what
11138        // a harness subscriber's own forwarder later logs having received
11139        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11140        // event"), so a gap between the two logs names itself instead of
11141        // needing a new counter added ad hoc.
11142        tracing::debug!(
11143            source_sequence = chunk.source_sequence,
11144            kind = agent_stream_chunk_kind_label(&chunk.kind),
11145            envelope_sequence = sequence,
11146            "publishing agent stream chunk",
11147        );
11148        let envelope = NodeEventEnvelope {
11149            sequence,
11150            event: NodeEvent::AgentStream { address, chunk },
11151        };
11152        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11153    }
11154
11155    fn current_sequence(&self) -> u64 {
11156        self.history
11157            .lock()
11158            .unwrap_or_else(|poisoned| poisoned.into_inner())
11159            .last_sequence
11160    }
11161
11162    fn controller_state(&self) -> Option<ControllerState> {
11163        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11164        let lease = controller.as_ref().copied()?;
11165        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11166        if remaining.is_zero() {
11167            *controller = None;
11168            return None;
11169        }
11170        Some(ControllerState {
11171            connection_id: lease.connection_id,
11172            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11173        })
11174    }
11175
11176    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11177        if role != ClientRole::Operator {
11178            tracing::warn!(
11179                node_id = %self.node_id,
11180                connection_id,
11181                "controller lease rejected: observer connections cannot acquire control",
11182            );
11183            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11184        }
11185        let lease_ms = if lease_ms == 0 {
11186            DEFAULT_CONTROLLER_LEASE_MS
11187        } else {
11188            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11189        };
11190        let now = Instant::now();
11191        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11192        if let Some(active) = controller.as_ref() {
11193            if active.connection_id != connection_id && active.expires_at > now {
11194                tracing::warn!(
11195                    node_id = %self.node_id,
11196                    connection_id,
11197                    holder_connection_id = active.connection_id,
11198                    "controller lease rejected: another operator holds it",
11199                );
11200                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11201            }
11202        }
11203        // A reconnect (this connection already holds the lease, renewing
11204        // before expiry) happens on a periodic cadence; logging it at INFO
11205        // every renewal would be exactly the heartbeat chatter this crate
11206        // family's level discipline forbids. A genuine attach -- the
11207        // controller identity actually changing -- is a state change and
11208        // gets INFO; a same-connection renewal gets DEBUG.
11209        let is_new_attach = match controller.as_ref() {
11210            Some(active) => active.connection_id != connection_id,
11211            None => true,
11212        };
11213        *controller = Some(ControllerLease {
11214            connection_id,
11215            expires_at: now + Duration::from_millis(lease_ms),
11216        });
11217        drop(controller);
11218        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11219        if is_new_attach {
11220            tracing::info!(
11221                node_id = %self.node_id,
11222                connection_id,
11223                lease_ms,
11224                "controller lease acquired",
11225            );
11226        } else {
11227            tracing::debug!(
11228                node_id = %self.node_id,
11229                connection_id,
11230                lease_ms,
11231                "controller lease renewed",
11232            );
11233        }
11234        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11235        Ok(state)
11236    }
11237
11238    fn release_controller(&self, connection_id: u64) -> bool {
11239        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11240        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11241        if released {
11242            *controller = None;
11243        }
11244        drop(controller);
11245        if released {
11246            tracing::info!(
11247                node_id = %self.node_id,
11248                connection_id,
11249                "controller lease released",
11250            );
11251            self.publish(NodeEvent::ControllerChanged { controller: None });
11252        }
11253        released
11254    }
11255
11256    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11257    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11258    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11259    /// provider session; it only cancels the orphan timer. Disconnect/reload
11260    /// draft kills are owned by HQ and arm only on a real client WS close.
11261    fn mark_spawn_provisional(&self, address: SessionAddress) {
11262        let id = address.session.instance_id;
11263        self.provisional_spawns
11264            .lock()
11265            .unwrap_or_else(|poisoned| poisoned.into_inner())
11266            .insert(id, (address, Instant::now()));
11267        tracing::info!(
11268            node_id = %self.node_id,
11269            instance_id = id.0,
11270            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11271            "draft spawn; waiting for HQ view",
11272        );
11273    }
11274
11275    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11276        let removed = self
11277            .provisional_spawns
11278            .lock()
11279            .unwrap_or_else(|poisoned| poisoned.into_inner())
11280            .remove(&instance_id);
11281        if removed.is_some() {
11282            tracing::info!(
11283                node_id = %self.node_id,
11284                instance_id = instance_id.0,
11285                "draft spawn claimed by HQ view",
11286            );
11287        }
11288    }
11289
11290    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11291        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11292        let mut map = self
11293            .provisional_spawns
11294            .lock()
11295            .unwrap_or_else(|poisoned| poisoned.into_inner());
11296        let due: Vec<AgentInstanceId> = map
11297            .iter()
11298            .filter(|(_, (_, at))| at.elapsed() >= grace)
11299            .map(|(id, _)| *id)
11300            .collect();
11301        due.into_iter()
11302            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11303            .collect()
11304    }
11305
11306    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11307        let instance_id = address.session.instance_id;
11308        tracing::warn!(
11309            node_id = %self.node_id,
11310            instance_id = instance_id.0,
11311            workspace = %address.workspace_id,
11312            reason = "attach_never_arrived",
11313            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11314            "draft spawn never viewed; stopping PTY",
11315        );
11316        let _ = self.dispatch(ControlCommand::Stop {
11317            instance_id,
11318            force: true,
11319        });
11320        let _ = self.remove_session(&address).await;
11321    }
11322
11323    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11324        if role != ClientRole::Operator {
11325            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11326        }
11327        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11328        let now = Instant::now();
11329        match controller.as_ref().copied() {
11330            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11331            Some(active) if active.expires_at <= now => {
11332                *controller = None;
11333                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11334            }
11335            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11336        }
11337    }
11338
11339    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11340        if !self
11341            .workspaces
11342            .read()
11343            .unwrap_or_else(|poisoned| poisoned.into_inner())
11344            .contains_key(&address.workspace_id)
11345        {
11346            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11347        }
11348        let binding = self
11349            .session_bindings
11350            .lock()
11351            .unwrap_or_else(|poisoned| poisoned.into_inner())
11352            .get(&address.session.instance_id)
11353            .cloned()
11354            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11355        if binding.workspace_id != address.workspace_id {
11356            return Err(failure(
11357                NodeFailureCode::SessionWorkspaceMismatch,
11358                "session belongs to another workspace",
11359            ));
11360        }
11361        if binding.generation != address.session.generation {
11362            return Err(failure(
11363                NodeFailureCode::StaleGeneration,
11364                &format!(
11365                    "stale session generation: expected {}, received {}",
11366                    binding.generation.0,
11367                    address.session.generation.0,
11368                ),
11369            ));
11370        }
11371        let snapshot = self.handle.snapshot();
11372        let Some(current) = snapshot
11373            .sessions
11374            .iter()
11375            .find(|item| item.instance_id == address.session.instance_id)
11376        else {
11377            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11378        };
11379        if current.generation != address.session.generation {
11380            return Err(failure(
11381                NodeFailureCode::StaleGeneration,
11382                &format!(
11383                    "stale session generation: expected {}, received {}",
11384                    current.generation.0,
11385                    address.session.generation.0,
11386                ),
11387            ));
11388        }
11389        Ok(current.agent_id.clone())
11390    }
11391
11392    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11393        self.dispatch_envelope(self.prepare_command(command))
11394    }
11395
11396    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11397        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11398        CommandEnvelope {
11399            id: CommandId(id),
11400            command,
11401        }
11402    }
11403
11404    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11405        let command_id = envelope.id;
11406        self.handle
11407            .dispatch(envelope)
11408            .map_err(|error| match error {
11409                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11410                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11411            })?;
11412        Ok(command_id)
11413    }
11414
11415    /// Records the kernel's own reason for asynchronously refusing
11416    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11417    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11418    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11419    /// first, and if the map is still at capacity after that, the single
11420    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11421    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11422    /// is always the oldest.
11423    fn record_rejected_command(
11424        &self,
11425        command_id: CommandId,
11426        message: String,
11427        unsupported_transport: bool,
11428    ) {
11429        let now = Instant::now();
11430        let mut reasons = self.rejected_command_reasons.lock()
11431            .unwrap_or_else(|poisoned| poisoned.into_inner());
11432        reasons.retain(|_, reason| reason.expires_at > now);
11433        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11434            if let Some(&oldest) = reasons.keys().next() {
11435                reasons.remove(&oldest);
11436            }
11437        }
11438        reasons.insert(
11439            command_id,
11440            RejectedCommandReason {
11441                message,
11442                unsupported_transport,
11443                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11444            },
11445        );
11446    }
11447
11448    /// Removes and returns the kernel's reason for refusing `command_id`,
11449    /// if the drive loop has recorded one and it has not expired -- see
11450    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11451    /// wait loop calls this instead of blindly polling out its whole
11452    /// deadline when the command it is waiting on turns out to have been
11453    /// refused rather than merely slow.
11454    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11455        let now = Instant::now();
11456        let mut reasons = self.rejected_command_reasons.lock()
11457            .unwrap_or_else(|poisoned| poisoned.into_inner());
11458        match reasons.remove(&command_id) {
11459            Some(reason) if reason.expires_at > now => Some(reason),
11460            _ => None,
11461        }
11462    }
11463
11464    async fn dispatch_bounded(
11465        &self,
11466        command: ControlCommand,
11467        timeout_duration: Duration,
11468    ) -> Result<CommandId, NodeFailure> {
11469        let deadline = Instant::now() + timeout_duration;
11470        loop {
11471            match self.dispatch(command.clone()) {
11472                Ok(command_id) => return Ok(command_id),
11473                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11474                    sleep(Duration::from_millis(2)).await;
11475                }
11476                Err(error) => return Err(error),
11477            }
11478        }
11479    }
11480
11481    fn internal_session_snapshot(
11482        &self,
11483        address: &SessionAddress,
11484    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11485        self.validate_address(address)?;
11486        self.handle
11487            .snapshot()
11488            .sessions
11489            .clone()
11490            .into_iter()
11491            .find(|session| {
11492                session.instance_id == address.session.instance_id
11493                    && session.generation == address.session.generation
11494            })
11495            .ok_or_else(|| {
11496                failure(
11497                    NodeFailureCode::UnknownSession,
11498                    "session snapshot is unavailable",
11499                )
11500            })
11501    }
11502
11503    /// Confirms `address` names a live session spawned over the ACP
11504    /// transport and returns its current snapshot -- the gate every
11505    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11506    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11507    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11508    /// the session's own recorded `TransportKind` does, and that lives on
11509    /// the live snapshot, not the binding.
11510    fn require_acp_session(
11511        &self,
11512        address: &SessionAddress,
11513    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11514        let snapshot = self.internal_session_snapshot(address)?;
11515        if snapshot.transport != TransportKind::Acp {
11516            return Err(failure(
11517                NodeFailureCode::UnsupportedTransport,
11518                "this verb requires a session spawned over the ACP transport",
11519            ));
11520        }
11521        Ok(snapshot)
11522    }
11523
11524    async fn dispatch_history_bounded(
11525        &self,
11526        address: &SessionAddress,
11527        command: ControlCommand,
11528        operation: HistoryOperation,
11529    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11530        self.validate_address(address)?;
11531        let started_after = self.current_sequence();
11532        let command_id = self
11533            .dispatch_bounded(
11534                command,
11535                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11536            )
11537            .await?;
11538        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11539        let mut scan_after = started_after;
11540        let mut accepted_after = None;
11541        loop {
11542            let events = self
11543                .history
11544                .lock()
11545                .unwrap_or_else(|poisoned| poisoned.into_inner())
11546                .events
11547                .iter()
11548                .filter(|envelope| envelope.sequence > scan_after)
11549                .cloned()
11550                .collect::<Vec<_>>();
11551            for envelope in events {
11552                scan_after = scan_after.max(envelope.sequence);
11553                let NodeEvent::Control {
11554                    address: event_address,
11555                    event,
11556                } = envelope.event
11557                else {
11558                    continue;
11559                };
11560                if event_address != *address {
11561                    continue;
11562                }
11563                if event.command_id == Some(command_id) {
11564                    match &event.event {
11565                        ControlEventKind::CommandRejected { .. } => {
11566                            return Err(failure(
11567                                NodeFailureCode::InvalidRequest,
11568                                "history request was rejected",
11569                            ));
11570                        }
11571                        ControlEventKind::HistoryRequested {
11572                            operation: requested,
11573                            ..
11574                        } if requested == &operation => {
11575                            accepted_after = Some(envelope.sequence);
11576                            continue;
11577                        }
11578                        _ => {}
11579                    }
11580                }
11581                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11582                    match (&operation, &event.event) {
11583                        (
11584                            HistoryOperation::Discover { .. },
11585                            ControlEventKind::HistoryDiscovered { .. },
11586                        )
11587                        | (
11588                            HistoryOperation::Load { .. },
11589                            ControlEventKind::HistoryLoaded { .. },
11590                        ) => return self.internal_session_snapshot(address),
11591                        (_, ControlEventKind::HistoryFailed { .. }) => {
11592                            return Err(failure(
11593                                NodeFailureCode::BackendOperationFailed,
11594                                "history operation failed",
11595                            ));
11596                        }
11597                        _ => {}
11598                    }
11599                }
11600            }
11601            if Instant::now() >= deadline {
11602                return Err(failure(
11603                    NodeFailureCode::BackendBusy,
11604                    if accepted_after.is_some() {
11605                        "history operation did not settle before the bounded deadline"
11606                    } else {
11607                        "history operation was not accepted before the bounded deadline"
11608                    },
11609                ));
11610            }
11611            sleep(Duration::from_millis(2)).await;
11612        }
11613    }
11614
11615    async fn discover_history(
11616        &self,
11617        address: &SessionAddress,
11618        limit: u16,
11619    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11620        let query = HistoryQuery {
11621            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11622            limit,
11623        };
11624        query
11625            .validate()
11626            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11627        let snapshot = self
11628            .dispatch_history_bounded(
11629                address,
11630                ControlCommand::DiscoverHistory {
11631                    instance_id: address.session.instance_id,
11632                    query: query.clone(),
11633                },
11634                HistoryOperation::Discover { query },
11635            )
11636            .await?;
11637        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11638            return Err(failure(
11639                NodeFailureCode::BackendOperationFailed,
11640                "history discovery settled into an invalid state",
11641            ));
11642        }
11643        Ok(snapshot.history.candidates)
11644    }
11645
11646    async fn load_history(
11647        &self,
11648        address: &SessionAddress,
11649        candidate_id: String,
11650    ) -> Result<HistorySessionRecord, NodeFailure> {
11651        validate_candidate_id(&candidate_id)
11652            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11653        let snapshot = self
11654            .dispatch_history_bounded(
11655                address,
11656                ControlCommand::LoadHistory {
11657                    instance_id: address.session.instance_id,
11658                    candidate_id: candidate_id.clone(),
11659                },
11660                HistoryOperation::Load {
11661                    candidate_id: candidate_id.clone(),
11662                },
11663            )
11664            .await?;
11665        if snapshot.history.pending.is_some()
11666            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11667        {
11668            return Err(failure(
11669                NodeFailureCode::BackendOperationFailed,
11670                "history load settled into an invalid state",
11671            ));
11672        }
11673        snapshot.history.loaded.ok_or_else(|| {
11674            failure(
11675                NodeFailureCode::BackendOperationFailed,
11676                "history load produced no bounded session",
11677            )
11678        })
11679    }
11680
11681    /// `allow_clean_detachment`: `false` for the on-demand
11682    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11683    /// managed record must stay untouched, still live, for the whole call);
11684    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11685    /// reacts to the very clean-exit event that detaches the record out from
11686    /// under this same call — see `session_record_export_target_matches`.
11687    async fn export_context_pack_for_session_record(
11688        &self,
11689        record_id: &SessionRecordId,
11690        session: &SessionAddress,
11691        allow_clean_detachment: bool,
11692    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11693        // C2 replaces a relayed failure's prose with a canned per-code
11694        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11695        // distinct refusals are indistinguishable to anyone downstream --
11696        // and a refusal here expires a continuation, which keeps no field
11697        // for why. The prose stays on the node, where it was written.
11698        let outcome = self
11699            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11700            .await;
11701        if let Err(failure) = &outcome {
11702            tracing::warn!(
11703                record = %record_id,
11704                code = ?failure.code,
11705                reason = %failure.message,
11706                "session-record context-pack export refused",
11707            );
11708        }
11709        outcome
11710    }
11711
11712    async fn export_context_pack_for_session_record_inner(
11713        &self,
11714        record_id: &SessionRecordId,
11715        session: &SessionAddress,
11716        allow_clean_detachment: bool,
11717    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11718        // Several steps here refuse with the same BackendOperationFailed
11719        // code and no prose of their own, so which step failed is
11720        // otherwise unrecoverable -- and a refusal here expires a
11721        // continuation.
11722        let step = |name: &'static str| move |failure: NodeFailure| {
11723            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11724            failure
11725        };
11726        let record = self.record(record_id).map_err(step("record-lookup"))?;
11727        // `provider_session` is `Some` only for a provider whose resolved
11728        // runtime policy admits live identity verification (raw PTY plus a
11729        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11730        // The PTY-sidecar-observation shape (no fleet provider today, but
11731        // capability-keyed rather than vendor-locked) hardcodes
11732        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11733        // so its records go straight to `Live` with `provider_session: None`
11734        // and stay that way for the record's entire life -- that is this
11735        // shape's permanent resting state, not a race with observation, and
11736        // `discover_history`/`load_history` below do not need an identity to
11737        // do their job for it. `IdentityPending` is the actual race (an
11738        // identity-admitted provider whose observation has not landed yet):
11739        // it keeps refusing here rather than falling back, because that
11740        // provider's own history the fallback would otherwise trust is not
11741        // guaranteed to be exact without the identity it is still waiting on.
11742        let identity = match record.provider_session.clone() {
11743            Some(identity) => {
11744                if identity.key != ProviderSessionKey::SessionId {
11745                    return Err(step("provider-identity-key")(failure(
11746                        NodeFailureCode::BackendOperationFailed,
11747                        "managed session provider identity cannot bind history exactly",
11748                    )));
11749                }
11750                Some(identity)
11751            }
11752            None if record.state == ManagedSessionState::IdentityPending => {
11753                return Err(step("provider-session-identity")(failure(
11754                    NodeFailureCode::BackendOperationFailed,
11755                    "managed session has not yet observed its provider session identity",
11756                )));
11757            }
11758            None => None,
11759        };
11760        self.revalidate_session_record_context_export(
11761            &record,
11762            identity.as_ref(),
11763            session,
11764            allow_clean_detachment,
11765        ).map_err(step("revalidate-before-discovery"))?;
11766        let candidates = self
11767            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11768            .await
11769            .map_err(step("discover-history"))?;
11770        // With no verified identity to filter by, the discovered set must
11771        // still resolve to exactly one candidate: the same ambiguity refusal
11772        // an identity filter would otherwise produce is the only safety
11773        // property left available for this shape.
11774        let candidate_id = select_history_candidate(
11775            &candidates,
11776            identity.as_ref().map(|identity| identity.id.as_str()),
11777        ).map_err(step("unique-candidate"))?;
11778        let loaded = self.load_history(session, candidate_id.clone()).await
11779            .map_err(step("load-history"))?;
11780        if let Some(identity) = identity.as_ref() {
11781            if loaded.session_id != identity.id {
11782                return Err(failure(
11783                    NodeFailureCode::SessionRecordConflict,
11784                    "loaded history identity does not match the managed session record",
11785                ));
11786            }
11787        }
11788        self.revalidate_session_record_context_export(
11789            &record,
11790            identity.as_ref(),
11791            session,
11792            allow_clean_detachment,
11793        )?;
11794        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11795            .map_err(step("revalidate-loaded-history"))?;
11796        let pack = self.materialize_context_pack(session).await
11797            .map_err(step("materialize-context-pack"))?;
11798        self.commit_context_pack_for_session_record(
11799            &record,
11800            identity.as_ref(),
11801            session,
11802            &candidate_id,
11803            &loaded,
11804            pack,
11805            allow_clean_detachment,
11806        )
11807    }
11808
11809    async fn export_context_pack(
11810        &self,
11811        address: &SessionAddress,
11812    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11813        let pack = self.materialize_context_pack(address).await?;
11814        self.commit_context_pack(pack)
11815    }
11816
11817    async fn materialize_context_pack(
11818        &self,
11819        address: &SessionAddress,
11820    ) -> Result<NodeContextPack, NodeFailure> {
11821        let snapshot = self.internal_session_snapshot(address)?;
11822        if !context_pack_source_status_is_usable(&snapshot.status) {
11823            return Err(failure(
11824                NodeFailureCode::InvalidRequest,
11825                "context export requires a running or successfully exited source session",
11826            ));
11827        }
11828        if snapshot.history.pending.is_some()
11829            || snapshot.history.loaded_candidate_id.is_none()
11830        {
11831            return Err(failure(
11832                NodeFailureCode::InvalidRequest,
11833                "context export requires settled loaded history",
11834            ));
11835        }
11836        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11837            failure(
11838                NodeFailureCode::InvalidRequest,
11839                "context export requires settled loaded history",
11840            )
11841        })?;
11842        let source_provider = snapshot.agent_id.clone();
11843        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11844        let repository = self
11845            .context_pack_repository(&address.workspace_id)
11846            .await?;
11847        let current = self.internal_session_snapshot(address)?;
11848        if !context_pack_source_status_is_usable(&current.status)
11849            || current.agent_id != source_provider
11850            || current.history.pending.is_some()
11851            || current.history.loaded_candidate_id != loaded_candidate_id
11852            || current.history.loaded.as_ref() != Some(&loaded)
11853        {
11854            return Err(failure(
11855                NodeFailureCode::ContextPackMaterializationFailed,
11856                "context source changed during bounded repository capture",
11857            ));
11858        }
11859        NodeContextPack::export_with_repository(
11860            ContextPackLineageReceipt {
11861                source_node_id: self.node_id.clone(),
11862                source_session: address.clone(),
11863                source_provider,
11864            },
11865            &loaded,
11866            Some(repository),
11867        )
11868        .map_err(|_| {
11869            failure(
11870                NodeFailureCode::ContextPackMaterializationFailed,
11871                "bounded context pack export failed",
11872            )
11873        })
11874    }
11875
11876    /// Write-through to the durable [`ContextPackStore`], if one is
11877    /// configured (`state_path` is `Some`). A node with no durable state
11878    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11879    /// open at all and this is a no-op, matching `persist_state`'s own
11880    /// "durability optional, degrade gracefully when unconfigured" idiom.
11881    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11882        let mut store = self
11883            .context_pack_store
11884            .lock()
11885            .unwrap_or_else(|poisoned| poisoned.into_inner());
11886        let Some(store) = store.as_mut() else {
11887            return Ok(());
11888        };
11889        store.commit(pack).map_err(|_| {
11890            failure(
11891                NodeFailureCode::ContextPackMaterializationFailed,
11892                "durable context pack commit failed",
11893            )
11894        })
11895    }
11896
11897    fn commit_context_pack(
11898        &self,
11899        pack: NodeContextPack,
11900    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11901        self.commit_context_pack_durably(&pack)?;
11902        self.context_catalog
11903            .write()
11904            .unwrap_or_else(|poisoned| poisoned.into_inner())
11905            .insert(pack)
11906            .map_err(|_| {
11907                failure(
11908                    NodeFailureCode::ContextPackMaterializationFailed,
11909                    "context pack catalog is full",
11910                )
11911            })
11912    }
11913
11914    fn commit_context_pack_for_session_record(
11915        &self,
11916        expected: &ManagedSessionRecord,
11917        identity: Option<&ProviderSessionIdentity>,
11918        session: &SessionAddress,
11919        candidate_id: &str,
11920        loaded: &HistorySessionRecord,
11921        pack: NodeContextPack,
11922        allow_clean_detachment: bool,
11923    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11924        let current_root = self.workspace_root(&expected.workspace_id)?;
11925        let _transaction = self
11926            .state_transaction
11927            .lock()
11928            .unwrap_or_else(|poisoned| poisoned.into_inner());
11929        let records = self
11930            .session_records
11931            .lock()
11932            .unwrap_or_else(|poisoned| poisoned.into_inner());
11933        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11934            failure(
11935                NodeFailureCode::SessionRecordConflict,
11936                "managed session record disappeared during context export",
11937            )
11938        })?;
11939        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11940            return Err(failure(
11941                NodeFailureCode::SessionRecordConflict,
11942                "managed session record changed during context export",
11943            ));
11944        }
11945        let bindings = self
11946            .session_bindings
11947            .lock()
11948            .unwrap_or_else(|poisoned| poisoned.into_inner());
11949        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11950            failure(
11951                NodeFailureCode::SessionRecordConflict,
11952                "managed session runtime binding disappeared during context export",
11953            )
11954        })?;
11955        let runtime = self
11956            .handle
11957            .snapshot()
11958            .sessions
11959            .iter()
11960            .find(|snapshot| {
11961                snapshot.instance_id == session.session.instance_id
11962                    && snapshot.generation == session.session.generation
11963            })
11964            .cloned()
11965            .ok_or_else(|| {
11966                failure(
11967                    NodeFailureCode::SessionRecordConflict,
11968                    "managed session runtime disappeared during context export",
11969                )
11970            })?;
11971        let receipt = pack.receipt();
11972        if !session_record_context_export_binding_is_exact(
11973            expected,
11974            current,
11975            identity,
11976            session,
11977            binding,
11978            &runtime.agent_id,
11979            &current_root,
11980            allow_clean_detachment,
11981        ) || !session_record_context_export_source_is_usable(
11982            current,
11983            &runtime.status,
11984            allow_clean_detachment,
11985        ) || runtime.history.pending.is_some()
11986            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11987            || runtime.history.loaded.as_ref() != Some(loaded)
11988            || receipt.lineage.source_node_id != self.node_id
11989            || receipt.lineage.source_session != *session
11990            || receipt.lineage.source_provider != current.provider
11991            || receipt.source_message_count != loaded.message_count
11992        {
11993            return Err(failure(
11994                NodeFailureCode::SessionRecordConflict,
11995                "managed session source changed before context catalog commit",
11996            ));
11997        }
11998        self.commit_context_pack_durably(&pack)?;
11999        self.context_catalog
12000            .write()
12001            .unwrap_or_else(|poisoned| poisoned.into_inner())
12002            .insert(pack)
12003            .map_err(|_| {
12004                failure(
12005                    NodeFailureCode::ContextPackMaterializationFailed,
12006                    "context pack catalog is full",
12007                )
12008            })
12009    }
12010
12011    async fn context_pack_repository(
12012        &self,
12013        workspace_id: &WorkspaceId,
12014    ) -> Result<ContextPackRepository, NodeFailure> {
12015        let first = self
12016            .context_pack_repository_observation(workspace_id)
12017            .await?;
12018        let second = self
12019            .context_pack_repository_observation(workspace_id)
12020            .await?;
12021        stable_context_pack_repository(first, second)
12022    }
12023
12024    async fn context_pack_repository_observation(
12025        &self,
12026        workspace_id: &WorkspaceId,
12027    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12028        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12029        let head = if git.is_repository {
12030            let root = self.workspace_root(workspace_id)?;
12031            match resolve_base_commit_with_timeout(
12032                &root,
12033                "HEAD",
12034                GIT_COMMAND_TIMEOUT_MS,
12035            )
12036            .await
12037            {
12038                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12039                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12040                    ContextPackRepositoryHead::Unborn
12041                }
12042                Err(_) => {
12043                    return Err(failure(
12044                        NodeFailureCode::ContextPackMaterializationFailed,
12045                        "context pack HEAD observation failed",
12046                    ));
12047                }
12048            }
12049        } else {
12050            ContextPackRepositoryHead::NotRepository
12051        };
12052        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12053        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12054            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12055                failure(
12056                    NodeFailureCode::InvalidRepositoryPath,
12057                    "context pack selected file path is invalid",
12058                )
12059            })?;
12060            match self.read_workspace_file(workspace_id.clone(), path).await {
12061                Ok(WorkspaceFileRead {
12062                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12063                    ..
12064                }) => files.push(ContextPackRepositoryFileSource::utf8(
12065                    selected_path,
12066                    text,
12067                    byte_len,
12068                )),
12069                Ok(WorkspaceFileRead {
12070                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12071                    ..
12072                }) => files.push(ContextPackRepositoryFileSource::skipped(
12073                    selected_path,
12074                    ContextPackSelectedFileSkipReason::NonUtf8,
12075                    Some(byte_len),
12076                )),
12077                Ok(WorkspaceFileRead {
12078                    content: WorkspaceFileContent::TooLarge { .. },
12079                    ..
12080                }) => files.push(ContextPackRepositoryFileSource::skipped(
12081                    selected_path,
12082                    ContextPackSelectedFileSkipReason::TooLarge,
12083                    None,
12084                )),
12085                Err(error) => {
12086                    let reason = if matches!(
12087                        error.code,
12088                        NodeFailureCode::InvalidRepositoryPath
12089                            | NodeFailureCode::RepositoryFileNotRegular
12090                            | NodeFailureCode::RepositoryPathUnsafe
12091                    ) {
12092                        ContextPackSelectedFileSkipReason::Unsafe
12093                    } else {
12094                        ContextPackSelectedFileSkipReason::Unavailable
12095                    };
12096                    files.push(ContextPackRepositoryFileSource::skipped(
12097                        selected_path,
12098                        reason,
12099                        None,
12100                    ));
12101                }
12102            }
12103        }
12104        Ok(ContextPackRepositoryObservation { head, git, files })
12105    }
12106
12107    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12108        let referenced_by_binding = self
12109            .session_bindings
12110            .lock()
12111            .unwrap_or_else(|poisoned| poisoned.into_inner())
12112            .values()
12113            .any(|binding| {
12114                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12115            });
12116        let referenced_by_record = self
12117            .session_records
12118            .lock()
12119            .unwrap_or_else(|poisoned| poisoned.into_inner())
12120            .records
12121            .values()
12122            .any(|record| record.context_id.as_ref() == Some(context_id));
12123        let referenced_by_materialization = self
12124            .materializations
12125            .lock()
12126            .unwrap_or_else(|poisoned| poisoned.into_inner())
12127            .values()
12128            .any(|ownership| {
12129                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12130            });
12131        if referenced_by_binding
12132            || referenced_by_record
12133            || referenced_by_materialization
12134        {
12135            return Err(failure(
12136                NodeFailureCode::ContextPackBusy,
12137                "context pack is still referenced",
12138            ));
12139        }
12140        let mut catalog = self.context_catalog
12141            .write()
12142            .unwrap_or_else(|poisoned| poisoned.into_inner());
12143        if catalog.get(context_id).is_none() {
12144            return Err(failure(
12145                NodeFailureCode::UnknownContextPack,
12146                "context pack is unavailable",
12147            ));
12148        }
12149        self.spawn_idempotency
12150            .lock()
12151            .unwrap_or_else(|poisoned| poisoned.into_inner())
12152            .entries
12153            .retain(|_, entry| !entry.value.references_context(context_id));
12154        let removed = catalog.remove(context_id);
12155        debug_assert!(removed.is_some(), "validated context pack remains present");
12156        Ok(())
12157    }
12158
12159    async fn dispatch_input_bounded(
12160        &self,
12161        address: &SessionAddress,
12162        action: InputAction,
12163    ) -> Result<(), NodeFailure> {
12164        let started_after = self.current_sequence();
12165        self.pty_input_marks
12166            .lock()
12167            .unwrap_or_else(|poisoned| poisoned.into_inner())
12168            .insert(address.session.instance_id, (Instant::now(), false));
12169        self.pty_input_wake.notify_one();
12170        let command_id = self
12171            .dispatch_bounded(
12172                ControlCommand::SendInput {
12173                    instance_id: address.session.instance_id,
12174                    action,
12175                },
12176                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12177            )
12178            .await?;
12179        // A semantic prompt may spend the provider's full readiness budget before
12180        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12181        // but strictly beyond every readiness policy in this node's catalog.
12182        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12183        let mut scan_after = started_after;
12184        let mut accepted_after = None;
12185        loop {
12186            let events = self
12187                .history
12188                .lock()
12189                .unwrap_or_else(|poisoned| poisoned.into_inner())
12190                .events
12191                .iter()
12192                .filter(|envelope| envelope.sequence > scan_after)
12193                .cloned()
12194                .collect::<Vec<_>>();
12195            for envelope in events {
12196                scan_after = scan_after.max(envelope.sequence);
12197                let NodeEvent::Control {
12198                    address: event_address,
12199                    event,
12200                } = envelope.event
12201                else {
12202                    continue;
12203                };
12204                if event_address != *address {
12205                    continue;
12206                }
12207                if event.command_id == Some(command_id) {
12208                    match &event.event {
12209                        ControlEventKind::CommandRejected { message } => {
12210                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12211                        }
12212                        ControlEventKind::InputRequested { .. } => {
12213                            accepted_after = Some(envelope.sequence);
12214                            continue;
12215                        }
12216                        _ => {}
12217                    }
12218                }
12219                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12220                    match &event.event {
12221                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12222                        ControlEventKind::InputFailed { message, .. } => {
12223                            return Err(failure(
12224                                NodeFailureCode::BackendOperationFailed,
12225                                message,
12226                            ));
12227                        }
12228                        _ => {}
12229                    }
12230                }
12231            }
12232            if Instant::now() >= deadline {
12233                return Err(failure(
12234                    NodeFailureCode::BackendBusy,
12235                    if accepted_after.is_some() {
12236                        "input was accepted but did not settle before the bounded deadline"
12237                    } else {
12238                        "input was not accepted before the bounded deadline"
12239                    },
12240                ));
12241            }
12242            sleep(Duration::from_millis(2)).await;
12243        }
12244    }
12245
12246    async fn dispatch_resize_bounded(
12247        &self,
12248        address: &SessionAddress,
12249        size: gate4agent_types::TerminalSize,
12250    ) -> Result<(), NodeFailure> {
12251        let started_after = self.current_sequence();
12252        let command_id = self
12253            .dispatch_bounded(
12254                ControlCommand::Resize {
12255                    instance_id: address.session.instance_id,
12256                    size,
12257                },
12258                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12259            )
12260            .await?;
12261        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12262        let mut scan_after = started_after;
12263        let mut accepted_after = None;
12264        loop {
12265            let events = self
12266                .history
12267                .lock()
12268                .unwrap_or_else(|poisoned| poisoned.into_inner())
12269                .events
12270                .iter()
12271                .filter(|envelope| envelope.sequence > scan_after)
12272                .cloned()
12273                .collect::<Vec<_>>();
12274            for envelope in events {
12275                scan_after = scan_after.max(envelope.sequence);
12276                let NodeEvent::Control {
12277                    address: event_address,
12278                    event,
12279                } = envelope.event
12280                else {
12281                    continue;
12282                };
12283                if event_address != *address {
12284                    continue;
12285                }
12286                if event.command_id == Some(command_id) {
12287                    match &event.event {
12288                        ControlEventKind::CommandRejected { message } => {
12289                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12290                        }
12291                        ControlEventKind::ResizeRequested {
12292                            size: requested_size,
12293                            ..
12294                        } if *requested_size == size => {
12295                            accepted_after = Some(envelope.sequence);
12296                            continue;
12297                        }
12298                        _ => {}
12299                    }
12300                }
12301                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12302                    match &event.event {
12303                        ControlEventKind::Resized { size: settled_size }
12304                            if *settled_size == size =>
12305                        {
12306                            return Ok(());
12307                        }
12308                        ControlEventKind::ResizeFailed { message } => {
12309                            return Err(failure(
12310                                NodeFailureCode::BackendOperationFailed,
12311                                message,
12312                            ));
12313                        }
12314                        _ => {}
12315                    }
12316                }
12317            }
12318            if Instant::now() >= deadline {
12319                return Err(failure(
12320                    NodeFailureCode::BackendBusy,
12321                    if accepted_after.is_some() {
12322                        "resize was accepted but did not settle before the bounded deadline"
12323                    } else {
12324                        "resize was not accepted before the bounded deadline"
12325                    },
12326                ));
12327            }
12328            sleep(Duration::from_millis(2)).await;
12329        }
12330    }
12331
12332    async fn spawn_session(
12333        &self,
12334        workspace_id: WorkspaceId,
12335        provider: AgentId,
12336        mode: SessionMode,
12337        terminal_size: gate4agent_types::TerminalSize,
12338        initial_prompt: Option<String>,
12339    ) -> Result<SessionAddress, NodeFailure> {
12340        if self.managed_worktrees.lock()
12341            .unwrap_or_else(|poisoned| poisoned.into_inner())
12342            .lease_for_workspace(&workspace_id)
12343            .is_some()
12344        {
12345            return Err(failure(
12346                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12347                "managed worktree targets may only be spawned through their lease request",
12348            ));
12349        }
12350        self
12351            .spawn_session_with_deadline(
12352                workspace_id,
12353                provider,
12354                mode,
12355                terminal_size,
12356                initial_prompt,
12357                None,
12358                None,
12359                None,
12360                None,
12361                None,
12362                SpawnRecordPolicy::ProviderIdentityOnly,
12363                None,
12364                &[],
12365                None,
12366                // `NodeRequest::Spawn` (this method's only caller) carries no
12367                // approval-level selection of its own -- see
12368                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12369                // the one operator-wire path that can choose something other
12370                // than the axis default.
12371                ApprovalLevel::default(),
12372                None,
12373                None,
12374            )
12375            .await
12376            .map(|(session, _runtime_policy)| session)
12377    }
12378
12379    async fn spawn_session_with_deadline(
12380        &self,
12381        workspace_id: WorkspaceId,
12382        provider: AgentId,
12383        mode: SessionMode,
12384        terminal_size: gate4agent_types::TerminalSize,
12385        initial_prompt: Option<String>,
12386        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12387        bundle: Option<ResolvedBundleReceipt>,
12388        context: Option<ResolvedContextPackReceipt>,
12389        managed_authority: Option<ManagedWorktreeLeaseId>,
12390        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12391        record_policy: SpawnRecordPolicy,
12392        deadline: Option<Instant>,
12393        required_capabilities: &[ProviderRuntimeCapability],
12394        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12395        approval_level: ApprovalLevel,
12396        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12397        // Opaque dig2browser station profile id (ids only). Feature
12398        // `dig2-station-probe`: after probe at resolve, spawn takes an
12399        // exclusive node-local lease; cleared on session end. Never
12400        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12401        // already happened on the receipt path).
12402        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12403    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12404        let reserved_lease = self.managed_worktrees.lock()
12405            .unwrap_or_else(|poisoned| poisoned.into_inner())
12406            .lease_for_workspace(&workspace_id);
12407        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12408            (Some(expected), Some(provided)) if expected == provided => {}
12409            (None, None) => {}
12410            _ => {
12411                return Err(failure(
12412                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12413                    "spawn lacks exact managed worktree lease authority",
12414                ));
12415            }
12416        }
12417        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12418        // bindings) on the same workspace cwd. Parallel path: managed worktree
12419        // spawn so each profile gets its own workspace_id.
12420        self.ensure_workspace_environment_profile_compatible(
12421            &workspace_id,
12422            environment_profile.as_ref(),
12423        )?;
12424        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12425            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12426            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12427            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12428            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12429        };
12430        // This session's own transport -- computed once, here, from `mode`,
12431        // and reused both to admit spawn (below) and, further down, to
12432        // compute the final `runtime_policy` this session actually operates
12433        // under (`provider_runtime::policy_for_transport`). Registration
12434        // reuses this exact value rather than recomputing it a second time.
12435        let transport = match mode {
12436            SessionMode::Pty => TransportKind::Pty,
12437            SessionMode::Inline => TransportKind::Pipe,
12438            SessionMode::Acp => TransportKind::Acp,
12439        };
12440        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12441        let acp_transport = self.provider_supports_acp_transport(&provider);
12442        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12443            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12444                NodeFailureCode::UnsupportedSpawnCapability,
12445                "pre-admitted provider runtime no longer satisfies spawn requirements",
12446            ))?;
12447            runtime_policy
12448        } else if let Some(deadline) = deadline {
12449            let remaining = spawn_deadline_remaining(deadline)?;
12450            timeout(
12451                remaining,
12452                self.admit_provider_runtime(&provider, runtime_requirement),
12453            )
12454            .await
12455            .map_err(|_| {
12456                failure(
12457                    NodeFailureCode::SpawnDeadlineExceeded,
12458                    "provider admission exceeded the spawn deadline",
12459                )
12460            })??
12461        } else {
12462            self.admit_provider_runtime(&provider, runtime_requirement).await?
12463        };
12464        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12465        // The single point where this session's `runtime_policy` becomes
12466        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12467        // branch above) only ever compute a PTY-terminal-verification-shaped
12468        // value, sound outright only for `TransportKind::Pty` (Pipe still
12469        // relies on that same value downstream -- see `policy_for_transport`'s
12470        // own doc for why). `policy_for_transport` is the explicit,
12471        // exhaustive match Item 1 asks for -- this is the ONE place its
12472        // output becomes the session's `runtime_policy`, so every later
12473        // reader (`ControlCommand::Start`, `session_bindings`, and every
12474        // engine gate that reads `state.runtime_policy`) sees the same,
12475        // correctly-computed value.
12476        let runtime_policy =
12477            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12478        if required_capabilities
12479            .iter()
12480            .any(|capability| !runtime_policy.admits(*capability))
12481        {
12482            return Err(failure(
12483                NodeFailureCode::UnsupportedSpawnCapability,
12484                "provider runtime does not admit a required spawn capability",
12485            ));
12486        }
12487        if let Some(deadline) = deadline {
12488            spawn_deadline_remaining(deadline)?;
12489        }
12490        self.ensure_binding_capacity()?;
12491        let working_directory = self.workspace_root(&workspace_id)?;
12492        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12493        let session = SessionKey {
12494            instance_id,
12495            generation: SessionGeneration(1),
12496        };
12497        let address = SessionAddress {
12498            workspace_id: workspace_id.clone(),
12499            session,
12500        };
12501        // This is the request-accepted checkpoint: admission (managed
12502        // worktree authority, runtime capability, dispatch deadline) already
12503        // passed and a concrete session identity is minted. It is a distinct
12504        // state from the process actually running (`ControlEventKind::Running`,
12505        // logged in `publish_control`) or the terminal outcome (`Exited`/
12506        // `Failed`, logged there too) -- this line exists so that gap is no
12507        // longer silent.
12508        tracing::info!(
12509            node_id = %self.node_id,
12510            workspace_id = %workspace_id,
12511            provider = %provider,
12512            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12513            session = ?address.session,
12514            "session spawn request accepted",
12515        );
12516        let mut prepared_spawn = SpawnPreparer::prepare(
12517            self,
12518            SpawnPrepareRequest {
12519                instance_id,
12520                address: &address,
12521                provider: &provider,
12522                mode,
12523                environment_profile: environment_profile.as_ref(),
12524                bundle: bundle.as_ref(),
12525                context: context.as_ref(),
12526                managed_authority: managed_authority.as_ref(),
12527                harness_mcp,
12528                approval_level,
12529                network_allowlist: network_allowlist.as_ref(),
12530                browser_profile_id,
12531            },
12532        )?;
12533        let materialization_id = prepared_spawn.materialization_id.take();
12534        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12535        let record_id = self.bind_spawn_session_with_materialization(
12536            &address,
12537            provider.clone(),
12538            mode,
12539            runtime_policy,
12540            record_policy,
12541            environment_profile,
12542            bundle,
12543            context,
12544            materialization_id,
12545        )?;
12546        prepared_spawn.retain_session_guards();
12547        // `transport` was already computed above, alongside this session's
12548        // `runtime_policy`; reused here for `Register` rather than derived a
12549        // second time.
12550        let agent_id = provider;
12551        let register_command_id = match self.dispatch_bounded(
12552            ControlCommand::Register {
12553                instance_id,
12554                agent_id,
12555                transport,
12556            },
12557            dispatch_timeout,
12558        )
12559        .await {
12560            Ok(command_id) => command_id,
12561            Err(error) => {
12562                if let Some(binding) = self.remove_binding(&address) {
12563                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12564                }
12565                if let Some(record_id) = record_id.as_ref() {
12566                    self.discard_record(record_id)?;
12567                }
12568                return Err(spawn_dispatch_error(error, deadline));
12569            }
12570        };
12571        let start_timeout = match spawn_dispatch_timeout(deadline) {
12572            Ok(timeout) => timeout,
12573            Err(error) => {
12574                let recovery = self
12575                    .rollback_spawn(
12576                        &address,
12577                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12578                    )
12579                    .await;
12580                return match recovery {
12581                    Ok(()) => {
12582                        if let Some(record_id) = record_id.as_ref() {
12583                            self.discard_record(record_id)?;
12584                        }
12585                        Err(error)
12586                    }
12587                    Err(recovery_error) => {
12588                        if let Some(record_id) = record_id.as_ref() {
12589                            self.mark_record_error(
12590                                record_id,
12591                                "spawn deadline elapsed and registration recovery failed",
12592                            )?;
12593                        }
12594                        Err(recovery_error)
12595                    }
12596                };
12597            }
12598        };
12599        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12600            if let Err(recovery_error) = self
12601                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12602                .await
12603            {
12604                return Err(recovery_error);
12605            }
12606            if let Some(record_id) = record_id.as_ref() {
12607                self.discard_record(record_id)?;
12608            }
12609            return Err(start_error);
12610        }
12611        let start_result = self
12612            .dispatch_bounded(
12613                ControlCommand::Start {
12614                    instance_id,
12615                    runtime_policy,
12616                    request: StartRequest {
12617                        working_directory,
12618                        terminal_size,
12619                        initial_prompt,
12620                        session_options: None,
12621                        approval_level,
12622                    },
12623                },
12624                start_timeout,
12625            )
12626            .await;
12627        let start_command_id = match start_result {
12628            Ok(command_id) => command_id,
12629            Err(start_error) => {
12630                let start_error = spawn_dispatch_error(start_error, deadline);
12631                let recovery = self
12632                    .rollback_spawn(
12633                        &address,
12634                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12635                    )
12636                    .await;
12637                return match recovery {
12638                    Ok(()) => {
12639                        if let Some(record_id) = record_id.as_ref() {
12640                            self.discard_record(record_id)?;
12641                        }
12642                        Err(start_error)
12643                    }
12644                    Err(recovery_error) => {
12645                        let diagnostic = format!(
12646                            "start failed and registration recovery failed: {}",
12647                            recovery_error.message,
12648                        );
12649                        if let Some(record_id) = record_id.as_ref() {
12650                            self.mark_record_error(record_id, &diagnostic)?;
12651                        }
12652                        Err(failure(recovery_error.code, &diagnostic))
12653                    }
12654                };
12655            }
12656        };
12657
12658        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12659        loop {
12660            // `Register`/`Start` only fail synchronously for a port-level
12661            // ingress problem (see `dispatch_bounded`); the kernel refusing
12662            // either command's content -- an unsupported transport, an
12663            // unknown agent -- lands one drive-loop tick later, and no
12664            // session ever appears in `self.handle.snapshot()` for it to
12665            // notice below. Check for that named reason before falling
12666            // through to the blind presence poll, so a rejected spawn
12667            // reports why instead of just timing out.
12668            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12669                .or_else(|| self.take_rejected_command_reason(start_command_id))
12670            {
12671                self.rollback_spawn(
12672                    &address,
12673                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12674                ).await.map_err(|error| {
12675                        failure(
12676                            error.code,
12677                            &format!(
12678                                "spawn was rejected ({}) and registration recovery failed: {}",
12679                                reason.message, error.message
12680                            ),
12681                        )
12682                    })?;
12683                if let Some(record_id) = record_id.as_ref() {
12684                    self.discard_record(record_id)?;
12685                }
12686                let code = if reason.unsupported_transport {
12687                    NodeFailureCode::UnsupportedTransport
12688                } else {
12689                    NodeFailureCode::BackendOperationFailed
12690                };
12691                return Err(failure(code, &reason.message));
12692            }
12693            if let Some(current) = self
12694                .handle
12695                .snapshot()
12696                .sessions
12697                .iter()
12698                .find(|current| current.instance_id == instance_id)
12699            {
12700                if current.generation != session.generation {
12701                    let divergence = failure(
12702                        NodeFailureCode::StaleGeneration,
12703                        "spawn generation diverged",
12704                    );
12705                    return match self
12706                        .rollback_spawn(
12707                            &address,
12708                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12709                        )
12710                        .await
12711                    {
12712                        Ok(()) => {
12713                            if let Some(record_id) = record_id.as_ref() {
12714                                self.discard_record(record_id)?;
12715                            }
12716                            Err(divergence)
12717                        }
12718                        Err(recovery_error) => {
12719                            if let Some(record_id) = record_id.as_ref() {
12720                                self.mark_record_error(
12721                                    record_id,
12722                                    "spawn generation diverged and recovery failed",
12723                                )?;
12724                            }
12725                            Err(recovery_error)
12726                        }
12727                    };
12728                }
12729                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12730                    prepared_spawn.retain_launch_guards();
12731                    return Ok((address, runtime_policy));
12732                }
12733            }
12734            if Instant::now() >= commit_deadline {
12735                self.rollback_spawn(
12736                    &address,
12737                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12738                ).await.map_err(|error| {
12739                        failure(
12740                            error.code,
12741                            &format!(
12742                                "start did not commit and registration recovery failed: {}",
12743                                error.message
12744                            ),
12745                        )
12746                    })?;
12747                if let Some(record_id) = record_id.as_ref() {
12748                    self.discard_record(record_id)?;
12749                }
12750                return Err(failure(
12751                    if deadline.is_some() {
12752                        NodeFailureCode::SpawnDeadlineExceeded
12753                    } else {
12754                        NodeFailureCode::BackendBusy
12755                    },
12756                    "start did not commit before the bounded deadline; registration was removed",
12757                ));
12758            }
12759            sleep(Duration::from_millis(2)).await;
12760        }
12761    }
12762
12763    fn effective_spawn_runtime_policy(
12764        &self,
12765        runtime_policy: ProviderRuntimePolicy,
12766    ) -> ProviderRuntimePolicy {
12767        #[cfg(feature = "fixture")]
12768        let runtime_policy = if self.fixture_semantic_hook_policy {
12769            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12770                .expect("monitoring Hook fixture policy is internally valid")
12771        } else {
12772            runtime_policy
12773        };
12774        runtime_policy
12775    }
12776
12777    fn verify_harness_mcp_before_start(
12778        &self,
12779        prepared: Option<&PreparedHarnessMcpSpawn>,
12780    ) -> Result<(), NodeFailure> {
12781        prepared
12782            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12783            .transpose()
12784            .map(|_| ())
12785            .map_err(harness_mcp_failure)
12786    }
12787
12788    async fn rollback_spawn(
12789        &self,
12790        address: &SessionAddress,
12791        commit_timeout: Duration,
12792    ) -> Result<(), NodeFailure> {
12793        self.dispatch_bounded(
12794            ControlCommand::Remove {
12795                instance_id: address.session.instance_id,
12796            },
12797            commit_timeout,
12798        )
12799        .await?;
12800        self.wait_until_removed(address, commit_timeout).await?;
12801        if let Some(binding) = self.remove_binding(address) {
12802            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12803        }
12804        Ok(())
12805    }
12806
12807    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12808        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12809        self.dispatch_bounded(
12810            ControlCommand::Remove {
12811                instance_id: address.session.instance_id,
12812            },
12813            commit_timeout,
12814        )
12815            .await?;
12816        self.wait_until_removed(address, commit_timeout).await?;
12817        let removed = self.remove_binding(address);
12818        if let Some(binding) = removed.as_ref() {
12819            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12820        }
12821        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12822            let snapshot = self.managed_worktrees.lock()
12823                .unwrap_or_else(|poisoned| poisoned.into_inner())
12824                .release_session(
12825                    &lease_id,
12826                    self.incarnation_id,
12827                    address.session.instance_id,
12828                    address.session.generation,
12829                    unix_time_ms(),
12830                );
12831            if self.persist_state().is_err() {
12832                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12833            }
12834            if let Some(snapshot) = snapshot {
12835                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12836            }
12837            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12838        }
12839        Ok(())
12840    }
12841
12842    /// Polls until `address`'s session leaves the engine's own session map,
12843    /// or the bounded deadline expires.
12844    ///
12845    /// A `Remove` sent while the session is not yet in a terminal status
12846    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12847    /// once by the engine (`SessionStatus::allows_remove`) and
12848    /// then discarded -- nothing re-issues it on its own, so the removal
12849    /// that would satisfy this wait never arrives and the caller was
12850    /// guaranteed to time out even though the session settles microseconds
12851    /// later. So this re-dispatches on every poll tick: a session that
12852    /// settles mid-wait -- the common case right after a `Stop` -- still
12853    /// gets removed inside the same bounded deadline.
12854    ///
12855    /// It re-dispatches only once the status will ACCEPT the command. A
12856    /// rejected re-dispatch is not the harmless no-op this loop first
12857    /// assumed: every rejection publishes a `ControlEventKind::Command
12858    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12859    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12860    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12861    /// out to the harness and every attached client. Gated, the same
12862    /// teardown sends exactly one `Remove`, at the first tick the engine
12863    /// can act on it.
12864    ///
12865    /// The status is read from the snapshot this loop already takes, so
12866    /// the gate costs nothing extra. It races -- the status can move
12867    /// between the read and the dispatch -- and that is fine: a rare lost
12868    /// race is one rejection, and the next tick retries.
12869    ///
12870    /// Re-dispatch is separately rate-limited to
12871    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12872    /// leaves a second burst behind it. Once the session settles, every
12873    /// 2ms tick sent another `Remove` while the accepted one was still in
12874    /// flight, and each of those landed on an instance the engine had
12875    /// already dropped -- `agent instance … is not registered`, observed
12876    /// live as a tail of rejections immediately after the `session
12877    /// removed` line. The presence poll stays at 2ms so the wait still
12878    /// exits promptly; only the sending slows down.
12879    ///
12880    /// Rate-limited rather than once-only on purpose: the status gate
12881    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12882    /// others (a pending operation, an in-flight capability probe or
12883    /// history request) that clear on their own. A single shot would turn
12884    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12885    /// get their retries, just twenty of them across the deadline instead
12886    /// of a thousand.
12887    async fn wait_until_removed(
12888        &self,
12889        address: &SessionAddress,
12890        commit_timeout: Duration,
12891    ) -> Result<(), NodeFailure> {
12892        let deadline = Instant::now() + commit_timeout;
12893        let mut next_dispatch = Instant::now();
12894        loop {
12895            let snapshot = self.handle.snapshot();
12896            let status = snapshot
12897                .sessions
12898                .iter()
12899                .find(|session| session.instance_id == address.session.instance_id)
12900                .map(|session| session.status.clone());
12901            let Some(status) = status else {
12902                return Ok(());
12903            };
12904            let now = Instant::now();
12905            if now >= deadline {
12906                return Err(failure(
12907                    NodeFailureCode::BackendBusy,
12908                    "remove did not commit before the bounded deadline; session binding was retained",
12909                ));
12910            }
12911            if status.allows_remove() && now >= next_dispatch {
12912                let _ = self.dispatch(ControlCommand::Remove {
12913                    instance_id: address.session.instance_id,
12914                });
12915                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12916            }
12917            sleep(Duration::from_millis(2)).await;
12918        }
12919    }
12920
12921    fn resync(&self, after_sequence: u64) -> NodeResponse {
12922        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12923        let event_sequence = history.last_sequence;
12924        let oldest_available_sequence = history.replay_floor_sequence;
12925        let events = history
12926            .events
12927            .iter()
12928            .filter(|event| event.sequence > after_sequence)
12929            .cloned()
12930            .collect();
12931        drop(history);
12932        NodeResponse::Resync {
12933            event_sequence,
12934            oldest_available_sequence,
12935            snapshot: self.snapshot(),
12936            events,
12937        }
12938    }
12939}
12940
12941/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12942/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12943/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12944/// "name what crossed this boundary" reasoning, one layer upstream of it).
12945fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12946    match kind {
12947        AgentStreamChunkKindV1::Text { .. } => "text",
12948        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12949        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12950        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12951        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12952        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12953        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12954    }
12955}
12956
12957/// Serves this wire on the local endpoint, and -- when `call_home` names a
12958/// relay -- over an outbound connection to it as well.
12959///
12960/// The two live in one future rather than a fourth arm of `run`'s own
12961/// `select!` because they are one concern: this is where the node's wire
12962/// is served, and the direction a given connection was opened in does not
12963/// change what happens on it. It also means the existing graceful drain
12964/// below covers both without being duplicated.
12965///
12966/// Either side finishing ends the pair, which is correct: the listener
12967/// only returns on shutdown or a bind failure, and `call_home_loop` only
12968/// returns on shutdown -- a failed relay connection is retried inside it,
12969/// never propagated out.
12970async fn accept_connections(
12971    endpoint: &str,
12972    call_home: Option<std::net::SocketAddr>,
12973    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12974    shared: Arc<NodeShared>,
12975) -> Result<(), NodeServerError> {
12976    let mut connections = JoinSet::new();
12977    let result = match call_home {
12978        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12979        Some(relay) => {
12980            let listener =
12981                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12982            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12983            tokio::pin!(listener);
12984            tokio::pin!(dialler);
12985            tokio::select! {
12986                result = &mut listener => result,
12987                result = &mut dialler => result,
12988            }
12989        }
12990    };
12991    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12992    while !connections.is_empty() {
12993        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12994        if remaining.is_zero()
12995            || timeout(remaining, connections.join_next()).await.is_err()
12996        {
12997            break;
12998        }
12999    }
13000    connections.abort_all();
13001    while connections.join_next().await.is_some() {}
13002    result
13003}
13004
13005/// Backoff ladder for a relay that will not answer, capped rather than
13006/// unbounded: a node whose relay is down is not broken and should keep
13007/// trying forever, but it should not spend a core doing it.
13008const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13009
13010/// Dials the relay, serves this wire over the connection, and does it
13011/// again when the connection ends.
13012///
13013/// The node is the SERVER on the socket it just opened. That inversion is
13014/// the whole point: `serve_connection` is the same function the local
13015/// listener feeds, and it neither knows nor cares which end placed the
13016/// call. The one thing this direction adds is the preface -- the relay
13017/// accepted a socket and cannot know whose it is, so the node says its
13018/// name before the handshake it would otherwise begin with.
13019///
13020/// Never returns an error. A relay that is down, refusing, or speaking a
13021/// different protocol is a condition to retry, not a reason to take the
13022/// node's local endpoint down with it; the only way out is shutdown.
13023async fn call_home_loop(
13024    relay: std::net::SocketAddr,
13025    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13026    shared: Arc<NodeShared>,
13027) -> Result<(), NodeServerError> {
13028    let mut failures = 0_usize;
13029    loop {
13030        if shared.shutdown.load(Ordering::Acquire) {
13031            return Ok(());
13032        }
13033        let permit = tokio::select! {
13034            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13035                permit.map_err(|_| NodeServerError::PreauthClosed)?
13036            }
13037            _ = shared.shutdown_notify.notified() => return Ok(()),
13038        };
13039        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13040            Ok(()) => {
13041                // A clean end is the relay hanging up, which it does on
13042                // its own restart. Reconnect immediately rather than
13043                // serving the backoff a peer that is plainly there.
13044                failures = 0;
13045                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13046            }
13047            Err(error) => {
13048                failures = failures.saturating_add(1);
13049                tracing::warn!(
13050                    node_id = %shared.node_id,
13051                    %relay,
13052                    cause = %error,
13053                    attempt = failures,
13054                    "call-home connection failed",
13055                );
13056                let backoff = CALL_HOME_BACKOFFS_MS
13057                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13058                tokio::select! {
13059                    _ = sleep(Duration::from_millis(backoff)) => {}
13060                    _ = shared.shutdown_notify.notified() => return Ok(()),
13061                }
13062            }
13063        }
13064    }
13065}
13066
13067/// One call-home attempt: connect, name this node, then serve.
13068async fn dial_relay_and_serve(
13069    relay: std::net::SocketAddr,
13070    identity: Option<&hatchery_enroll::NodeIdentity>,
13071    shared: Arc<NodeShared>,
13072    preauth_permit: OwnedSemaphorePermit,
13073) -> Result<(), NodeServerError> {
13074    let mut stream = tokio::net::TcpStream::connect(relay)
13075        .await
13076        .map_err(NodeServerError::CallHomeTransport)?;
13077    // Nagle off for the same reason the rest of this wire wants it off:
13078    // the traffic is small request/response frames, and coalescing them
13079    // adds latency to every one.
13080    let _ = stream.set_nodelay(true);
13081    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13082        .await
13083        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13084    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13085    let mut session_token = None;
13086    if let Some(identity) = identity {
13087        // An enrolled relay answers the announce with a signed-call-home challenge; a
13088        // token-mode relay sends its wire hello instead. Peek to tell them apart
13089        // without consuming either.
13090        let mut magic = [0_u8; 4];
13091        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13092            .await
13093            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13094            .map_err(NodeServerError::CallHomeTransport)?;
13095        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13096            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13097            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13098                .await
13099                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13100                .map_err(NodeServerError::CallHomeTransport)?;
13101            let (answer, token) = identity
13102                .answer_challenge(shared.node_id.as_str(), &frame)
13103                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13104            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13105            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13106            session_token = Some(token);
13107        }
13108    }
13109    serve_connection(stream, shared, session_token, preauth_permit).await
13110}
13111
13112async fn accept_connections_inner(
13113    endpoint: &str,
13114    shared: Arc<NodeShared>,
13115    connections: &mut JoinSet<Result<(), NodeServerError>>,
13116) -> Result<(), NodeServerError> {
13117    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13118    loop {
13119        while connections.try_join_next().is_some() {}
13120        if shared.shutdown.load(Ordering::Acquire) {
13121            return Ok(());
13122        }
13123        let preauth_permit = tokio::select! {
13124            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13125                permit.map_err(|_| NodeServerError::PreauthClosed)?
13126            }
13127            _ = shared.shutdown_notify.notified() => return Ok(()),
13128        };
13129        let server = tokio::select! {
13130            result = listener.accept() => result?,
13131            _ = shared.shutdown_notify.notified() => return Ok(()),
13132        };
13133        let connection_shared = Arc::clone(&shared);
13134        connections.spawn(async move {
13135            serve_connection(server, connection_shared, None, preauth_permit).await
13136        });
13137        if shared.shutdown.load(Ordering::Acquire) {
13138            return Ok(());
13139        }
13140    }
13141}
13142
13143/// Serves one connection, whichever end opened it.
13144///
13145/// Generic over the stream rather than typed to `LocalServerStream`
13146/// because a call-home connection arrives on a `TcpStream` the node itself
13147/// dialled. Nothing in the body ever depended on the concrete type -- it
13148/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13149/// the whole of what welding "who dials" to "who serves" was costing.
13150async fn serve_connection<S>(
13151    mut pipe: S,
13152    shared: Arc<NodeShared>,
13153    session_token: Option<String>,
13154    preauth_permit: OwnedSemaphorePermit,
13155) -> Result<(), NodeServerError>
13156where
13157    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13158{
13159    // A signed call-home connection authenticates with its own per-connection token.
13160    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13161    let hello_frame = timeout(
13162        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13163        read_json_frame_limited_body_timeout(
13164            &mut pipe,
13165            MAX_NODE_HELLO_FRAME_BYTES,
13166            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13167        ),
13168    )
13169    .await
13170    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13171    let ClientFrame::Hello(hello) = hello_frame else {
13172        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13173    };
13174    if hello.build_stamp != BUILD_STAMP {
13175        return Err(NodeServerError::Handshake(format!(
13176            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13177            hello.build_stamp,
13178        )));
13179    }
13180    let compatibility = match hello.compatibility.as_ref() {
13181        Some(offer) => {
13182            let mut selected = node_compatibility_support(&shared)?
13183                .negotiate(offer)
13184                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13185            project_negotiated_provider_ids(&mut selected);
13186            Some(selected)
13187        }
13188        None => None,
13189    };
13190    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13191    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13192        (Some(offer), Some(selected)) => negotiated_auth_proof(
13193            auth_token,
13194            AuthDirection::Server,
13195            hello.role,
13196            &hello.client_nonce,
13197            &server_nonce,
13198            offer,
13199            selected,
13200        ),
13201        (None, None) => auth_proof(
13202            auth_token,
13203            AuthDirection::Server,
13204            hello.role,
13205            &hello.client_nonce,
13206            &server_nonce,
13207        ),
13208        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13209    }
13210    .map_err(NodeServerError::Authentication)?;
13211    write_json_frame_limited(
13212        &mut pipe,
13213        &ServerFrame::Challenge(ServerChallenge {
13214            build_stamp: BUILD_STAMP.to_owned(),
13215            server_nonce,
13216            server_proof,
13217            compatibility: compatibility.clone(),
13218        }),
13219        MAX_NODE_HELLO_FRAME_BYTES,
13220    )
13221    .await?;
13222    let authentication = timeout(
13223        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13224        read_json_frame_limited_body_timeout(
13225            &mut pipe,
13226            MAX_NODE_HELLO_FRAME_BYTES,
13227            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13228        ),
13229    )
13230    .await
13231    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13232    let ClientFrame::Authenticate(authentication) = authentication else {
13233        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13234    };
13235    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13236        (Some(offer), Some(selected)) => negotiated_auth_proof(
13237            auth_token,
13238            AuthDirection::Client,
13239            hello.role,
13240            &hello.client_nonce,
13241            &server_nonce,
13242            offer,
13243            selected,
13244        ),
13245        (None, None) => auth_proof(
13246            auth_token,
13247            AuthDirection::Client,
13248            hello.role,
13249            &hello.client_nonce,
13250            &server_nonce,
13251        ),
13252        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13253    }
13254    .map_err(NodeServerError::Authentication)?;
13255    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13256        return Err(NodeServerError::Handshake("access denied".to_owned()));
13257    }
13258    let selected_capabilities = compatibility
13259        .as_ref()
13260        .map(|selected| selected.capabilities.clone())
13261        .unwrap_or_default();
13262    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13263        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13264    });
13265    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13266        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13267    });
13268    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13269        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13270    });
13271    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13272        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13273    });
13274    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13275        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13276    });
13277    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13278        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13279    }) && selected_capabilities.iter().any(|capability| {
13280        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13281    });
13282    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13283        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13284    });
13285    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13286        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13287    });
13288    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13289        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13290    });
13291    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13292        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13293    });
13294    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13295        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13296    });
13297    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13298        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13299    });
13300    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13301        .try_acquire_owned()
13302        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13303    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13304    let _release_guard = ControllerReleaseGuard {
13305        shared: Arc::clone(&shared),
13306        connection_id,
13307    };
13308    drop(preauth_permit);
13309    let _authenticated_permit = authenticated_permit;
13310    let mut event_rx = shared.event_tx.subscribe();
13311    let mut terminal_event_rx = terminal_event_subscription(
13312        &shared,
13313        include_terminal_frame_events,
13314    );
13315    let mut agent_stream_event_rx = agent_stream_event_subscription(
13316        &shared,
13317        include_agent_stream_events,
13318    );
13319    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13320        &shared,
13321        include_harness_mcp_proxy,
13322    );
13323    write_json_frame(
13324        &mut pipe,
13325        &ServerFrame::Hello(NodeHello {
13326            build_stamp: BUILD_STAMP.to_owned(),
13327            incarnation_id: shared.incarnation_id,
13328            connection_id,
13329            role: hello.role,
13330            event_sequence: shared.current_sequence(),
13331            controller: shared.controller_state(),
13332            snapshot: snapshot_for_wire(
13333                &shared,
13334                include_provider_runtime_status,
13335                include_open_provider_ids,
13336                include_spawn_profiles,
13337                include_managed_worktrees,
13338                include_child_environment_profiles,
13339                include_session_bundles,
13340                include_history_context_packs,
13341                include_agent_progress,
13342                include_session_task_correlation,
13343            ),
13344            compatibility,
13345        }),
13346    )
13347    .await?;
13348    let (mut reader, mut writer) = tokio::io::split(pipe);
13349    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13350    let reader_task = tokio::spawn(async move {
13351        loop {
13352            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13353                &mut reader,
13354                MAX_NODE_CLIENT_FRAME_BYTES,
13355                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13356            )
13357            .await;
13358            let terminal = frame.is_err();
13359            if frame_tx.send(frame).await.is_err() || terminal {
13360                break;
13361            }
13362        }
13363    });
13364    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13365    let mut pending_events = Vec::new();
13366    let mut resync_required = false;
13367    let mut discard_events_through = 0_u64;
13368    loop {
13369        // One tick of the serve loop, whether or not it had anything to do.
13370        // Against `connection_events_sent` this separates "busy" from
13371        // "spinning": a loop turning over thousands of times a second while
13372        // sending nothing is not serving, it is burning a core.
13373        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13374        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13375            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13376        for _ in 0..durable_drain_budget {
13377            match event_rx.try_recv() {
13378                Ok(event) => queue_connection_event(
13379                    &mut pending_events,
13380                    event,
13381                    discard_events_through,
13382                ),
13383                Err(broadcast::error::TryRecvError::Empty) => break,
13384                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13385                    resync_required = true;
13386                    break;
13387                }
13388                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13389            }
13390        }
13391        if let Some(receiver) = terminal_event_rx.as_mut() {
13392            match receiver.try_recv() {
13393                Ok(events) => queue_connection_event_batch(
13394                    &mut pending_events,
13395                    &events,
13396                    discard_events_through,
13397                ),
13398                Err(broadcast::error::TryRecvError::Empty) => {}
13399                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13400                    resync_required = true;
13401                }
13402                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13403            }
13404        }
13405        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13406            // Loops up to the same remaining-room budget the durable
13407            // `event_rx` drain above uses, rather than one `try_recv` per
13408            // tick: each successful receive here is itself only ONE
13409            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13410            // sends `vec![envelope]`, never more), so a single-shot drain
13411            // caps this connection's agent-stream throughput at one chunk
13412            // per outer-loop tick regardless of how many are already queued
13413            // -- exactly the kind of self-inflicted stall this crate's own
13414            // durable-event budget exists to avoid.
13415            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13416                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13417            for _ in 0..agent_stream_drain_budget {
13418                match receiver.try_recv() {
13419                    Ok(events) => queue_connection_event_batch(
13420                        &mut pending_events,
13421                        &events,
13422                        discard_events_through,
13423                    ),
13424                    Err(broadcast::error::TryRecvError::Empty) => break,
13425                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13426                        tracing::warn!(
13427                            connection_id,
13428                            dropped,
13429                            "agent stream event subscription lagged; forcing a resync",
13430                        );
13431                        resync_required = true;
13432                        break;
13433                    }
13434                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13435                }
13436            }
13437        }
13438        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13439            // Mirrors the agent-stream drain immediately above: each
13440            // successful receive is one single-envelope batch
13441            // (`publish_transient` always sends `vec![envelope]`), so this
13442            // loops up to the same remaining-room budget rather than
13443            // stopping after one `try_recv`.
13444            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13445                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13446            for _ in 0..harness_mcp_proxy_drain_budget {
13447                match receiver.try_recv() {
13448                    Ok(events) => queue_connection_event_batch(
13449                        &mut pending_events,
13450                        &events,
13451                        discard_events_through,
13452                    ),
13453                    Err(broadcast::error::TryRecvError::Empty) => break,
13454                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13455                        tracing::warn!(
13456                            connection_id,
13457                            dropped,
13458                            "harness MCP proxy event subscription lagged; forcing a resync",
13459                        );
13460                        resync_required = true;
13461                        break;
13462                    }
13463                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13464                }
13465            }
13466        }
13467        if resync_required {
13468            let event = resync_required_event(&shared);
13469            apply_connection_resync_watermark(
13470                &mut pending_events,
13471                &mut discard_events_through,
13472                event.sequence,
13473            );
13474            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13475            resync_required = false;
13476            continue;
13477        }
13478        if !pending_events.is_empty() {
13479            pending_events.sort_unstable_by_key(|event| event.sequence);
13480            let burst_len = connection_event_burst_len(pending_events.len());
13481            shared
13482                .connection_events_sent
13483                .fetch_add(burst_len as u64, Ordering::Relaxed);
13484            for event in pending_events.drain(..burst_len) {
13485                let event = if include_managed_worktrees {
13486                    Some(event)
13487                } else {
13488                    project_event_without_managed_worktrees(event)
13489                };
13490                let event = event.and_then(|event| if include_open_provider_ids {
13491                    Some(event)
13492                } else {
13493                    project_event_legacy_provider_ids(&shared, event)
13494                });
13495                let event = event.map(|event| if include_child_environment_profiles {
13496                    event
13497                } else {
13498                    project_event_without_child_environment_profile(event)
13499                });
13500                let event = event.map(|event| if include_session_bundles {
13501                    event
13502                } else {
13503                    project_event_without_session_bundle(event)
13504                });
13505                let event = event.map(|event| {
13506                    project_event_history_for_wire(event, include_history_context_packs)
13507                });
13508                let event = event.map(|event| if include_history_context_packs {
13509                    event
13510                } else {
13511                    project_event_without_context_pack(event)
13512                });
13513                let event = event.map(|event| if include_session_task_correlation {
13514                    event
13515                } else {
13516                    project_event_without_session_task_binding(event)
13517                });
13518                let exact_harness_controller = hello.role == ClientRole::Operator
13519                    && shared.controller_state().is_some_and(|controller| {
13520                        controller.connection_id == connection_id
13521                    });
13522                let event = event.and_then(|event| {
13523                    if include_harness_mcp_proxy && exact_harness_controller {
13524                        Some(event)
13525                    } else {
13526                        project_event_without_harness_mcp_proxy(event)
13527                    }
13528                });
13529                if let Some(event) = event {
13530                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13531                }
13532            }
13533        }
13534        tokio::select! {
13535            biased;
13536            frame = frame_rx.recv() => {
13537                let frame = match frame {
13538                    Some(frame) => frame,
13539                    None => break,
13540                };
13541                let frame = match frame {
13542                    Ok(frame) => frame,
13543                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13544                    Err(error) => return Err(error.into()),
13545                };
13546                let ClientFrame::Request(request) = frame else {
13547                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13548                };
13549                #[cfg(feature = "fixture")]
13550                let probe_spawn_managed_worktree_v2 = matches!(
13551                    &request.request,
13552                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13553                );
13554                let requires_open_provider_ids =
13555                    request_requires_open_provider_ids(&shared, &request.request);
13556                let requires_child_environment_profile =
13557                    request_requires_child_environment_profile(&shared, &request.request);
13558                let requires_session_bundle =
13559                    request_requires_session_bundle(&shared, &request.request);
13560                let requires_history_context_pack =
13561                    request_requires_history_context_pack(&shared, &request.request);
13562                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13563                    ResponseEnvelope {
13564                        request_id: request.request_id,
13565                        result: Err(failure(
13566                            NodeFailureCode::UnsupportedCapability,
13567                            "open provider IDs were not negotiated",
13568                        )),
13569                    }
13570                } else if requires_child_environment_profile
13571                    && !include_child_environment_profiles
13572                {
13573                    ResponseEnvelope {
13574                        request_id: request.request_id,
13575                        result: Err(failure(
13576                            NodeFailureCode::UnsupportedCapability,
13577                            "child environment profiles were not negotiated",
13578                        )),
13579                    }
13580                } else if requires_session_bundle && !include_session_bundles {
13581                    ResponseEnvelope {
13582                        request_id: request.request_id,
13583                        result: Err(failure(
13584                            NodeFailureCode::UnsupportedCapability,
13585                            "session bundles were not negotiated",
13586                        )),
13587                    }
13588                } else if requires_history_context_pack && !include_history_context_packs {
13589                    ResponseEnvelope {
13590                        request_id: request.request_id,
13591                        result: Err(failure(
13592                            NodeFailureCode::UnsupportedCapability,
13593                            "history context packs were not negotiated",
13594                        )),
13595                    }
13596                } else if request_uses_unnegotiated_capability(
13597                    &request.request,
13598                    &selected_capabilities,
13599                )
13600                {
13601                    ResponseEnvelope {
13602                        request_id: request.request_id,
13603                        result: Err(failure(
13604                            NodeFailureCode::UnsupportedCapability,
13605                            "request capability was not negotiated",
13606                        )),
13607                    }
13608                } else {
13609                    process_request(&shared, connection_id, hello.role, request).await
13610                };
13611                #[cfg(feature = "fixture")]
13612                if probe_spawn_managed_worktree_v2 {
13613                    shared
13614                        .fixture_spawn_managed_worktree_v2_failure_probe
13615                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13616                }
13617                if !include_provider_runtime_status {
13618                    clear_response_provider_runtime_status(&mut reply);
13619                }
13620                if !include_managed_worktrees {
13621                    project_response_without_managed_worktrees(&mut reply);
13622                }
13623                if !include_open_provider_ids {
13624                    project_response_legacy_provider_ids(&shared, &mut reply);
13625                }
13626                if !include_child_environment_profiles {
13627                    project_response_without_child_environment_profile(&mut reply);
13628                }
13629                if !include_session_bundles {
13630                    project_response_without_session_bundle(&mut reply);
13631                }
13632                project_response_history_for_wire(
13633                    &mut reply,
13634                    include_history_context_packs,
13635                );
13636                if !include_history_context_packs {
13637                    project_response_without_context_pack(&mut reply);
13638                }
13639                if !include_agent_progress {
13640                    project_response_without_agent_progress(&mut reply);
13641                }
13642                if !include_session_task_correlation {
13643                    project_response_without_session_task_binding(&mut reply);
13644                }
13645                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13646            }
13647            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13648            event = event_rx.recv() => {
13649                match event {
13650                    Ok(event) => queue_connection_event(
13651                        &mut pending_events,
13652                        event,
13653                        discard_events_through,
13654                    ),
13655                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13656                    Err(broadcast::error::RecvError::Closed) => break,
13657                }
13658            }
13659            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13660                match events {
13661                    Ok(events) => queue_connection_event_batch(
13662                        &mut pending_events,
13663                        &events,
13664                        discard_events_through,
13665                    ),
13666                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13667                    Err(broadcast::error::RecvError::Closed) => break,
13668                }
13669            }
13670            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13671                match events {
13672                    Ok(events) => queue_connection_event_batch(
13673                        &mut pending_events,
13674                        &events,
13675                        discard_events_through,
13676                    ),
13677                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13678                    Err(broadcast::error::RecvError::Closed) => break,
13679                }
13680            }
13681            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13682                match events {
13683                    Ok(events) => queue_connection_event_batch(
13684                        &mut pending_events,
13685                        &events,
13686                        discard_events_through,
13687                    ),
13688                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13689                    Err(broadcast::error::RecvError::Closed) => break,
13690                }
13691            }
13692            _ = shared.shutdown_notify.notified() => break,
13693        }
13694    }
13695    Ok(())
13696}
13697
13698async fn receive_terminal_event_batch(
13699    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13700) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13701    match receiver {
13702        Some(receiver) => receiver.recv().await,
13703        None => std::future::pending().await,
13704    }
13705}
13706
13707fn terminal_event_subscription(
13708    shared: &NodeShared,
13709    enabled: bool,
13710) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13711    enabled.then(|| shared.terminal_event_tx.subscribe())
13712}
13713
13714/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13715/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13716/// this needs a dedicated channel rather than a flag on the terminal one.
13717fn agent_stream_event_subscription(
13718    shared: &NodeShared,
13719    enabled: bool,
13720) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13721    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13722}
13723
13724/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13725/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13726/// own doc for why this needs a dedicated channel rather than a ride on
13727/// the durable `event_tx`.
13728fn harness_mcp_proxy_event_subscription(
13729    shared: &NodeShared,
13730    enabled: bool,
13731) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13732    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13733}
13734
13735fn queue_connection_event(
13736    pending: &mut Vec<NodeEventEnvelope>,
13737    event: NodeEventEnvelope,
13738    discard_through: u64,
13739) {
13740    if event.sequence <= discard_through
13741        && !event.event.requires_harness_mcp_proxy_capability()
13742    {
13743        return;
13744    }
13745    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13746        if let Some(index) = pending.iter().position(|current| {
13747            matches!(
13748                &current.event,
13749                NodeEvent::TerminalFrame {
13750                    address: current_address,
13751                    ..
13752                } if current_address == address
13753            )
13754        }) {
13755            pending.remove(index);
13756        }
13757    }
13758    pending.push(event);
13759}
13760
13761fn project_event_without_harness_mcp_proxy(
13762    envelope: NodeEventEnvelope,
13763) -> Option<NodeEventEnvelope> {
13764    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13765}
13766
13767fn queue_connection_event_batch(
13768    pending: &mut Vec<NodeEventEnvelope>,
13769    events: &[NodeEventEnvelope],
13770    discard_through: u64,
13771) {
13772    for event in events {
13773        queue_connection_event(pending, event.clone(), discard_through);
13774    }
13775}
13776
13777fn apply_connection_resync_watermark(
13778    pending: &mut Vec<NodeEventEnvelope>,
13779    discard_through: &mut u64,
13780    marker_sequence: u64,
13781) {
13782    *discard_through = (*discard_through).max(marker_sequence);
13783    pending.retain(|event| event.sequence > *discard_through);
13784}
13785
13786fn connection_event_burst_len(pending_len: usize) -> usize {
13787    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13788}
13789
13790fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13791    let history = shared
13792        .history
13793        .lock()
13794        .unwrap_or_else(|poisoned| poisoned.into_inner());
13795    let sequence = history.last_sequence;
13796    let oldest_available_sequence = history.replay_floor_sequence;
13797    NodeEventEnvelope {
13798        sequence,
13799        event: NodeEvent::ResyncRequired {
13800            oldest_available_sequence,
13801        },
13802    }
13803}
13804
13805fn node_compatibility_support(
13806    shared: &NodeShared,
13807) -> Result<NodeCompatibilitySupport, NodeServerError> {
13808    let mut support = node_compatibility_support_for_manifest(
13809        &shared.provider_contracts,
13810        &shared.provider_adapter_contracts,
13811    )?;
13812    if shared
13813        .delivery_store
13814        .lock()
13815        .unwrap_or_else(|poisoned| poisoned.into_inner())
13816        .is_some()
13817    {
13818        support.capabilities.push(
13819            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13820                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13821        );
13822    }
13823    if shared.harness_mcp_registry.is_some() {
13824        support.capabilities.push(
13825            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13826                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13827        );
13828    }
13829    Ok(support)
13830}
13831
13832fn node_compatibility_support_for_manifest(
13833    provider_contracts: &[ProviderContractSupport],
13834    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13835) -> Result<NodeCompatibilitySupport, NodeServerError> {
13836    Ok(NodeCompatibilitySupport {
13837        build_stamp: BUILD_STAMP.to_owned(),
13838        capabilities: baseline_capabilities()?,
13839        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13840        path_semantics: platform::path_semantics(),
13841        local_transport: platform::local_transport(),
13842        state_schema: StateSchemaSupport {
13843            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13844                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13845        },
13846        provider_contracts: provider_contracts.to_vec(),
13847        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13848    })
13849}
13850
13851fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13852    let capabilities = [
13853        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13854        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13855        NODE_REPOSITORY_PATH_CAPABILITY,
13856        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13857        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13858        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13859        NODE_GIT_READ_CAPABILITY,
13860        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13861        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13862        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13863        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13864        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13865        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13866        NODE_ACP_CONTROL_CAPABILITY,
13867        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13868        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13869        NODE_WORKTREE_SELECTION_CAPABILITY,
13870        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13871        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13872        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13873        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13874        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13875        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13876        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13877        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13878        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13879        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13880        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13881        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13882    ]
13883        .into_iter()
13884        .map(|capability| {
13885            CapabilityId::new(capability)
13886                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13887        })
13888        .collect::<Result<Vec<_>, _>>()?;
13889    #[cfg(windows)]
13890    let capabilities = {
13891        let mut capabilities = capabilities;
13892        capabilities.push(
13893            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13894                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13895        );
13896        capabilities
13897    };
13898    Ok(capabilities)
13899}
13900
13901fn request_uses_unnegotiated_capability(
13902    request: &NodeRequest,
13903    selected_capabilities: &[CapabilityId],
13904) -> bool {
13905    let selected = |required: &str| {
13906        selected_capabilities
13907            .iter()
13908            .any(|capability| capability.as_str() == required)
13909    };
13910    request
13911        .required_capability()
13912        .is_some_and(|required| !selected(required))
13913        || (request.requires_worktree_selection_capability()
13914            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13915        || (request.requires_spawn_spec_defaults_overrides_capability()
13916            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13917        || (request.requires_spawn_profile_revision_capability()
13918            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13919        || (request.requires_child_environment_profile_capability()
13920            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13921        || (request.requires_session_bundle_materialization_capability()
13922            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13923        || (request.requires_history_context_pack_capability()
13924            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13925}
13926
13927fn snapshot_for_wire(
13928    shared: &NodeShared,
13929    include_provider_runtime_status: bool,
13930    include_open_provider_ids: bool,
13931    include_spawn_profiles: bool,
13932    include_managed_worktrees: bool,
13933    include_child_environment_profiles: bool,
13934    include_session_bundles: bool,
13935    include_history_context_packs: bool,
13936    include_agent_progress: bool,
13937    include_session_task_correlation: bool,
13938) -> NodeSnapshot {
13939    let mut snapshot = shared.snapshot();
13940    if !include_provider_runtime_status {
13941        snapshot.provider_runtime_statuses.clear();
13942    }
13943    if !include_open_provider_ids {
13944        project_snapshot_legacy_provider_ids(&mut snapshot);
13945    }
13946    if !include_managed_worktrees {
13947        snapshot.managed_worktrees.clear();
13948        for workspace in &mut snapshot.workspaces {
13949            workspace.managed_worktree_profiles = None;
13950        }
13951    }
13952    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13953        if !include_spawn_profiles {
13954            inventory.spawn_profiles = None;
13955        }
13956        if !include_session_bundles {
13957            inventory.bundles = None;
13958        }
13959        if inventory.spawn_profiles.is_none()
13960            && inventory.bundles.is_none()
13961            && inventory.network_allowlists.is_none()
13962        {
13963            snapshot.launch_inventory = None;
13964        }
13965    }
13966    if !include_child_environment_profiles {
13967        clear_snapshot_child_environment_profiles(&mut snapshot);
13968    }
13969    if !include_session_bundles {
13970        clear_snapshot_session_bundles(&mut snapshot);
13971    }
13972    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13973    if !include_history_context_packs {
13974        clear_snapshot_context_packs(&mut snapshot);
13975    }
13976    if !include_agent_progress {
13977        snapshot.agent_progress.clear();
13978    }
13979    if !include_session_task_correlation {
13980        clear_snapshot_session_task_bindings(&mut snapshot);
13981    }
13982    snapshot
13983}
13984
13985fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13986    for record in &mut snapshot.session_records {
13987        record.task_binding = None;
13988    }
13989}
13990
13991fn project_event_without_session_task_binding(
13992    mut envelope: NodeEventEnvelope,
13993) -> NodeEventEnvelope {
13994    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13995        record.task_binding = None;
13996    }
13997    envelope
13998}
13999
14000fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14001    let Ok(response) = reply.result.as_mut() else { return };
14002    match response {
14003        NodeResponse::Snapshot { snapshot, .. } => {
14004            clear_snapshot_session_task_bindings(snapshot);
14005        }
14006        NodeResponse::Resync { snapshot, events, .. } => {
14007            clear_snapshot_session_task_bindings(snapshot);
14008            for event in events {
14009                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14010                    record.task_binding = None;
14011                }
14012            }
14013        }
14014        NodeResponse::SessionRecordUpdated { record }
14015        | NodeResponse::ProviderSessionIndexed { record }
14016        | NodeResponse::NativeSessionIndexed { record, .. }
14017        | NodeResponse::SessionRecordResumed { record, .. } => {
14018            record.task_binding = None;
14019        }
14020        _ => {}
14021    }
14022}
14023
14024fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14025    let Ok(response) = reply.result.as_mut() else { return };
14026    match response {
14027        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14028            snapshot.agent_progress.clear();
14029        }
14030        _ => {}
14031    }
14032}
14033
14034fn project_event_without_managed_worktrees(
14035    mut envelope: NodeEventEnvelope,
14036) -> Option<NodeEventEnvelope> {
14037    if matches!(
14038        envelope.event,
14039        NodeEvent::ManagedWorktreeUpserted { .. }
14040            | NodeEvent::ManagedWorktreeRemoved { .. }
14041    ) {
14042        return None;
14043    }
14044    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14045        workspace.managed_worktree_profiles = None;
14046    }
14047    Some(envelope)
14048}
14049
14050fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14051    if matches!(
14052        reply.result,
14053        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14054            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14055    ) {
14056        reply.result = Err(failure(
14057            NodeFailureCode::UnsupportedCapability,
14058            "managed worktree capability was not negotiated",
14059        ));
14060        return;
14061    }
14062    let Ok(response) = reply.result.as_mut() else { return };
14063    match response {
14064        NodeResponse::Snapshot { snapshot, .. } => {
14065            snapshot.managed_worktrees.clear();
14066            for workspace in &mut snapshot.workspaces {
14067                workspace.managed_worktree_profiles = None;
14068            }
14069        }
14070        NodeResponse::Resync { snapshot, events, .. } => {
14071            snapshot.managed_worktrees.clear();
14072            for workspace in &mut snapshot.workspaces {
14073                workspace.managed_worktree_profiles = None;
14074            }
14075            *events = std::mem::take(events)
14076                .into_iter()
14077                .filter_map(project_event_without_managed_worktrees)
14078                .collect();
14079        }
14080        NodeResponse::WorkspaceRegistered { workspace }
14081        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14082        | NodeResponse::WorktreeCreated { workspace, .. } => {
14083            workspace.managed_worktree_profiles = None;
14084        }
14085        NodeResponse::WorkspaceInspected { inspection } => {
14086            inspection.git.managed_worktree = None;
14087        }
14088        _ => {}
14089    }
14090}
14091
14092fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14093    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14094        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14095    });
14096    if includes_open_provider_ids {
14097        return;
14098    }
14099    compatibility
14100        .provider_contracts
14101        .retain(|contract| provider_id_is_legacy(&contract.provider));
14102    compatibility
14103        .provider_adapter_contracts
14104        .retain(|contract| provider_id_is_legacy(&contract.provider));
14105}
14106
14107fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14108    if let Some(spec) = match request {
14109        NodeRequest::SpawnSpec { spec }
14110        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14111        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14112        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14113        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14114        _ => None,
14115    } {
14116        return shared
14117            .resolve_spawn_spec(spec)
14118            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14119    }
14120    request_requires_open_provider_ids_with(
14121        request,
14122        |session| shared.validate_address(session).ok(),
14123        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14124    )
14125}
14126
14127fn request_requires_child_environment_profile(
14128    shared: &NodeShared,
14129    request: &NodeRequest,
14130) -> bool {
14131    if let Some(spec) = match request {
14132        NodeRequest::SpawnSpec { spec }
14133        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14134        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14135        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14136        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14137        _ => None,
14138    } {
14139        return shared
14140            .resolve_spawn_spec(spec)
14141            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14142    }
14143    match request {
14144        NodeRequest::Resume { session, .. }
14145        | NodeRequest::Prompt { session, .. }
14146        | NodeRequest::Paste { session, .. }
14147        | NodeRequest::Input { session, .. }
14148        | NodeRequest::TerminalBytes { session, .. }
14149        | NodeRequest::TerminalControl { session, .. }
14150        | NodeRequest::Resize { session, .. }
14151        | NodeRequest::Interrupt { session }
14152        | NodeRequest::Stop { session, .. }
14153        | NodeRequest::Remove { session }
14154        | NodeRequest::ResolveInteraction { session, .. }
14155        | NodeRequest::SetSessionMode { session, .. }
14156        | NodeRequest::SetSessionConfigOption { session, .. }
14157        | NodeRequest::SetSessionModel { session, .. }
14158        | NodeRequest::DiscoverHistory { session, .. }
14159        | NodeRequest::LoadHistory { session, .. }
14160        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14161        | NodeRequest::ExportContextPack { session } => shared
14162            .session_bindings
14163            .lock()
14164            .unwrap_or_else(|poisoned| poisoned.into_inner())
14165            .get(&session.session.instance_id)
14166            .filter(|binding| {
14167                binding.workspace_id == session.workspace_id
14168                    && binding.generation == session.session.generation
14169            })
14170            .map_or(true, |binding| binding.environment_profile.is_some()),
14171        NodeRequest::RenameSessionRecord { record_id, .. }
14172        | NodeRequest::SetSessionTask { record_id, .. }
14173        | NodeRequest::ResumeSessionRecord { record_id, .. }
14174        | NodeRequest::ForgetSessionRecord { record_id } => shared
14175            .record(record_id)
14176            .map_or(true, |record| record.environment_profile.is_some()),
14177        NodeRequest::ArmHarnessMcpReservation { .. }
14178        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14179        | NodeRequest::ActivateHarnessMcpReservation { .. }
14180        | NodeRequest::AbortHarnessMcpReservation { .. }
14181        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14182        | NodeRequest::RejectHarnessMcpCall { .. }
14183        | NodeRequest::Snapshot
14184        | NodeRequest::Resync { .. }
14185        | NodeRequest::BeginDeliveryStage { .. }
14186        | NodeRequest::PutDeliveryBlobChunk { .. }
14187        | NodeRequest::CommitDeliveryStage { .. }
14188        | NodeRequest::AbortDeliveryStage { .. }
14189        | NodeRequest::BrowseHostDirectories { .. }
14190        | NodeRequest::InspectWorkspace { .. }
14191        | NodeRequest::ReadWorkspaceFile { .. }
14192        | NodeRequest::WriteWorkspaceFile { .. }
14193        | NodeRequest::CreateWorkspaceFile { .. }
14194        | NodeRequest::CreateWorkspaceDirectory { .. }
14195        | NodeRequest::ReadGitHistory { .. }
14196        | NodeRequest::ReadGitDiff { .. }
14197        | NodeRequest::AcquireController { .. }
14198        | NodeRequest::ReleaseController
14199        | NodeRequest::Spawn { .. }
14200        | NodeRequest::SpawnSpec { .. }
14201        | NodeRequest::RegisterWorkspace { .. }
14202        | NodeRequest::CreateStandaloneWorkspace { .. }
14203        | NodeRequest::UnregisterWorkspace { .. }
14204        | NodeRequest::CreateWorktree { .. }
14205        | NodeRequest::RemoveWorktree { .. }
14206        | NodeRequest::SpawnManagedWorktree { .. }
14207        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14208        | NodeRequest::CleanupManagedWorktree { .. }
14209        | NodeRequest::IndexProviderSession { .. }
14210        | NodeRequest::IndexNativeSession { .. }
14211        | NodeRequest::CatalogNativeSessions { .. }
14212        | NodeRequest::PageNativeSessions { .. }
14213        | NodeRequest::PreviewNativeSession { .. }
14214        | NodeRequest::PreviewSessionRecord { .. }
14215        | NodeRequest::ForgetContextPack { .. }
14216        | NodeRequest::ResolveDurableContextPack { .. }
14217        | NodeRequest::ReadContextPack { .. }
14218        | NodeRequest::Shutdown => false,
14219    }
14220}
14221
14222fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14223    if let Some(spec) = match request {
14224        NodeRequest::SpawnSpec { spec }
14225        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14226        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14227        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14228        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14229        _ => None,
14230    } {
14231        return shared
14232            .resolve_spawn_spec(spec)
14233            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14234    }
14235    match request {
14236        NodeRequest::Resume { session, .. }
14237        | NodeRequest::Prompt { session, .. }
14238        | NodeRequest::Paste { session, .. }
14239        | NodeRequest::Input { session, .. }
14240        | NodeRequest::TerminalBytes { session, .. }
14241        | NodeRequest::TerminalControl { session, .. }
14242        | NodeRequest::Resize { session, .. }
14243        | NodeRequest::Interrupt { session }
14244        | NodeRequest::Stop { session, .. }
14245        | NodeRequest::Remove { session }
14246        | NodeRequest::ResolveInteraction { session, .. }
14247        | NodeRequest::SetSessionMode { session, .. }
14248        | NodeRequest::SetSessionConfigOption { session, .. }
14249        | NodeRequest::SetSessionModel { session, .. }
14250        | NodeRequest::DiscoverHistory { session, .. }
14251        | NodeRequest::LoadHistory { session, .. }
14252        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14253        | NodeRequest::ExportContextPack { session } => shared
14254            .session_bindings
14255            .lock()
14256            .unwrap_or_else(|poisoned| poisoned.into_inner())
14257            .get(&session.session.instance_id)
14258            .filter(|binding| {
14259                binding.workspace_id == session.workspace_id
14260                    && binding.generation == session.session.generation
14261            })
14262            .map_or(true, |binding| binding.bundle.is_some()),
14263        NodeRequest::RenameSessionRecord { record_id, .. }
14264        | NodeRequest::SetSessionTask { record_id, .. }
14265        | NodeRequest::ResumeSessionRecord { record_id, .. }
14266        | NodeRequest::ForgetSessionRecord { record_id } => shared
14267            .record(record_id)
14268            .map_or(true, |record| record.bundle.is_some()),
14269        NodeRequest::ArmHarnessMcpReservation { .. }
14270        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14271        | NodeRequest::ActivateHarnessMcpReservation { .. }
14272        | NodeRequest::AbortHarnessMcpReservation { .. }
14273        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14274        | NodeRequest::RejectHarnessMcpCall { .. }
14275        | NodeRequest::Snapshot
14276        | NodeRequest::Resync { .. }
14277        | NodeRequest::BeginDeliveryStage { .. }
14278        | NodeRequest::PutDeliveryBlobChunk { .. }
14279        | NodeRequest::CommitDeliveryStage { .. }
14280        | NodeRequest::AbortDeliveryStage { .. }
14281        | NodeRequest::BrowseHostDirectories { .. }
14282        | NodeRequest::InspectWorkspace { .. }
14283        | NodeRequest::ReadWorkspaceFile { .. }
14284        | NodeRequest::WriteWorkspaceFile { .. }
14285        | NodeRequest::CreateWorkspaceFile { .. }
14286        | NodeRequest::CreateWorkspaceDirectory { .. }
14287        | NodeRequest::ReadGitHistory { .. }
14288        | NodeRequest::ReadGitDiff { .. }
14289        | NodeRequest::AcquireController { .. }
14290        | NodeRequest::ReleaseController
14291        | NodeRequest::Spawn { .. }
14292        | NodeRequest::SpawnSpec { .. }
14293        | NodeRequest::RegisterWorkspace { .. }
14294        | NodeRequest::CreateStandaloneWorkspace { .. }
14295        | NodeRequest::UnregisterWorkspace { .. }
14296        | NodeRequest::CreateWorktree { .. }
14297        | NodeRequest::RemoveWorktree { .. }
14298        | NodeRequest::SpawnManagedWorktree { .. }
14299        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14300        | NodeRequest::CleanupManagedWorktree { .. }
14301        | NodeRequest::IndexProviderSession { .. }
14302        | NodeRequest::IndexNativeSession { .. }
14303        | NodeRequest::CatalogNativeSessions { .. }
14304        | NodeRequest::PageNativeSessions { .. }
14305        | NodeRequest::PreviewNativeSession { .. }
14306        | NodeRequest::PreviewSessionRecord { .. }
14307        | NodeRequest::ForgetContextPack { .. }
14308        | NodeRequest::ResolveDurableContextPack { .. }
14309        | NodeRequest::ReadContextPack { .. }
14310        | NodeRequest::Shutdown => false,
14311    }
14312}
14313
14314fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14315    if request.requires_history_context_pack_capability() {
14316        return true;
14317    }
14318    if let Some(spec) = match request {
14319        NodeRequest::SpawnSpec { spec }
14320        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14321        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14322        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14323        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14324        _ => None,
14325    } {
14326        return shared
14327            .resolve_spawn_spec(spec)
14328            .map_or(true, |resolved| resolved.context_id.is_some());
14329    }
14330    match request {
14331        NodeRequest::Resume { session, .. }
14332        | NodeRequest::Prompt { session, .. }
14333        | NodeRequest::Paste { session, .. }
14334        | NodeRequest::Input { session, .. }
14335        | NodeRequest::TerminalBytes { session, .. }
14336        | NodeRequest::TerminalControl { session, .. }
14337        | NodeRequest::Resize { session, .. }
14338        | NodeRequest::Interrupt { session }
14339        | NodeRequest::Stop { session, .. }
14340        | NodeRequest::Remove { session }
14341        | NodeRequest::ResolveInteraction { session, .. }
14342        | NodeRequest::SetSessionMode { session, .. }
14343        | NodeRequest::SetSessionConfigOption { session, .. }
14344        | NodeRequest::SetSessionModel { session, .. } => shared
14345            .session_bindings
14346            .lock()
14347            .unwrap_or_else(|poisoned| poisoned.into_inner())
14348            .get(&session.session.instance_id)
14349            .filter(|binding| {
14350                binding.workspace_id == session.workspace_id
14351                    && binding.generation == session.session.generation
14352            })
14353            .map_or(true, |binding| binding.context.is_some()),
14354        NodeRequest::RenameSessionRecord { record_id, .. }
14355        | NodeRequest::SetSessionTask { record_id, .. }
14356        | NodeRequest::ResumeSessionRecord { record_id, .. }
14357        | NodeRequest::ForgetSessionRecord { record_id } => shared
14358            .record(record_id)
14359            .map_or(true, |record| record.context.is_some()),
14360        NodeRequest::ArmHarnessMcpReservation { .. }
14361        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14362        | NodeRequest::ActivateHarnessMcpReservation { .. }
14363        | NodeRequest::AbortHarnessMcpReservation { .. }
14364        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14365        | NodeRequest::RejectHarnessMcpCall { .. }
14366        | NodeRequest::Snapshot
14367        | NodeRequest::Resync { .. }
14368        | NodeRequest::BeginDeliveryStage { .. }
14369        | NodeRequest::PutDeliveryBlobChunk { .. }
14370        | NodeRequest::CommitDeliveryStage { .. }
14371        | NodeRequest::AbortDeliveryStage { .. }
14372        | NodeRequest::BrowseHostDirectories { .. }
14373        | NodeRequest::InspectWorkspace { .. }
14374        | NodeRequest::ReadWorkspaceFile { .. }
14375        | NodeRequest::WriteWorkspaceFile { .. }
14376        | NodeRequest::CreateWorkspaceFile { .. }
14377        | NodeRequest::CreateWorkspaceDirectory { .. }
14378        | NodeRequest::ReadGitHistory { .. }
14379        | NodeRequest::ReadGitDiff { .. }
14380        | NodeRequest::AcquireController { .. }
14381        | NodeRequest::ReleaseController
14382        | NodeRequest::Spawn { .. }
14383        | NodeRequest::SpawnSpec { .. }
14384        | NodeRequest::RegisterWorkspace { .. }
14385        | NodeRequest::CreateStandaloneWorkspace { .. }
14386        | NodeRequest::UnregisterWorkspace { .. }
14387        | NodeRequest::CreateWorktree { .. }
14388        | NodeRequest::RemoveWorktree { .. }
14389        | NodeRequest::SpawnManagedWorktree { .. }
14390        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14391        | NodeRequest::CleanupManagedWorktree { .. }
14392        | NodeRequest::IndexProviderSession { .. }
14393        | NodeRequest::IndexNativeSession { .. }
14394        | NodeRequest::CatalogNativeSessions { .. }
14395        | NodeRequest::PageNativeSessions { .. }
14396        | NodeRequest::PreviewNativeSession { .. }
14397        | NodeRequest::PreviewSessionRecord { .. }
14398        | NodeRequest::DiscoverHistory { .. }
14399        | NodeRequest::LoadHistory { .. }
14400        | NodeRequest::ExportContextPackForSessionRecord { .. }
14401        | NodeRequest::ExportContextPack { .. }
14402        | NodeRequest::ForgetContextPack { .. }
14403        | NodeRequest::ResolveDurableContextPack { .. }
14404        | NodeRequest::ReadContextPack { .. }
14405        | NodeRequest::Shutdown => false,
14406    }
14407}
14408
14409fn request_requires_open_provider_ids_with(
14410    request: &NodeRequest,
14411    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14412    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14413) -> bool {
14414    match request {
14415        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14416        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14417        NodeRequest::CatalogNativeSessions { route, .. }
14418        | NodeRequest::PageNativeSessions { route, .. } => {
14419            !provider_id_is_legacy(&route.provider)
14420        }
14421        NodeRequest::PreviewNativeSession { selection, .. }
14422        | NodeRequest::IndexNativeSession { selection, .. } => {
14423            !provider_id_is_legacy(&selection.route.provider)
14424        }
14425        NodeRequest::SpawnSpec { .. }
14426        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14427        NodeRequest::SpawnManagedWorktree { .. }
14428        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14429        NodeRequest::Resume { session, .. }
14430        | NodeRequest::Prompt { session, .. }
14431        | NodeRequest::Paste { session, .. }
14432        | NodeRequest::Input { session, .. }
14433        | NodeRequest::TerminalBytes { session, .. }
14434        | NodeRequest::TerminalControl { session, .. }
14435        | NodeRequest::Resize { session, .. }
14436        | NodeRequest::Interrupt { session }
14437        | NodeRequest::Stop { session, .. }
14438        | NodeRequest::Remove { session }
14439        | NodeRequest::ResolveInteraction { session, .. }
14440        | NodeRequest::SetSessionMode { session, .. }
14441        | NodeRequest::SetSessionConfigOption { session, .. }
14442        | NodeRequest::SetSessionModel { session, .. }
14443        | NodeRequest::DiscoverHistory { session, .. }
14444        | NodeRequest::LoadHistory { session, .. }
14445        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14446        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14447            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14448        NodeRequest::RenameSessionRecord { record_id, .. }
14449        | NodeRequest::SetSessionTask { record_id, .. }
14450        | NodeRequest::ResumeSessionRecord { record_id, .. }
14451        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14452            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14453        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14454            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14455        NodeRequest::ArmHarnessMcpReservation { .. }
14456        | NodeRequest::ActivateHarnessMcpReservation { .. }
14457        | NodeRequest::AbortHarnessMcpReservation { .. }
14458        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14459        | NodeRequest::RejectHarnessMcpCall { .. }
14460        | NodeRequest::Snapshot
14461        | NodeRequest::Resync { .. }
14462        | NodeRequest::BeginDeliveryStage { .. }
14463        | NodeRequest::PutDeliveryBlobChunk { .. }
14464        | NodeRequest::CommitDeliveryStage { .. }
14465        | NodeRequest::AbortDeliveryStage { .. }
14466        | NodeRequest::BrowseHostDirectories { .. }
14467        | NodeRequest::InspectWorkspace { .. }
14468        | NodeRequest::ReadWorkspaceFile { .. }
14469        | NodeRequest::WriteWorkspaceFile { .. }
14470        | NodeRequest::CreateWorkspaceFile { .. }
14471        | NodeRequest::CreateWorkspaceDirectory { .. }
14472        | NodeRequest::ReadGitHistory { .. }
14473        | NodeRequest::ReadGitDiff { .. }
14474        | NodeRequest::AcquireController { .. }
14475        | NodeRequest::ReleaseController
14476        | NodeRequest::RegisterWorkspace { .. }
14477        | NodeRequest::CreateStandaloneWorkspace { .. }
14478        | NodeRequest::UnregisterWorkspace { .. }
14479        | NodeRequest::CreateWorktree { .. }
14480        | NodeRequest::RemoveWorktree { .. }
14481        | NodeRequest::CleanupManagedWorktree { .. }
14482        | NodeRequest::Shutdown => false,
14483        NodeRequest::ForgetContextPack { .. } => true,
14484        // No session/record to cheaply resolve a provider from here either;
14485        // the resolved receipt can carry an open-id provider, so require the
14486        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14487        // is the same digest-keyed lookup one step further into the same
14488        // catalog, so it inherits the same conservative answer.
14489        NodeRequest::ResolveDurableContextPack { .. }
14490        | NodeRequest::ReadContextPack { .. } => true,
14491    }
14492}
14493
14494fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14495    snapshot.enabled_providers.retain(provider_id_is_legacy);
14496    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14497        snapshot
14498            .provider_runtime_statuses
14499            .as_slice()
14500            .iter()
14501            .filter(|status| provider_id_is_legacy(status.provider()))
14502            .cloned(),
14503    )
14504    .expect("a filtered provider runtime projection remains valid");
14505    snapshot
14506        .session_records
14507        .retain(|record| provider_id_is_legacy(&record.provider));
14508    for workspace in &mut snapshot.workspaces {
14509        workspace
14510            .sessions
14511            .retain(|session| provider_id_is_legacy(&session.agent_id));
14512    }
14513}
14514
14515fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14516    for record in &mut snapshot.session_records {
14517        record.environment_profile = None;
14518    }
14519    if let Some(profiles) = snapshot
14520        .launch_inventory
14521        .as_mut()
14522        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14523    {
14524        for profile in profiles {
14525            profile.environment_profile = None;
14526        }
14527    }
14528}
14529
14530fn project_event_without_child_environment_profile(
14531    mut envelope: NodeEventEnvelope,
14532) -> NodeEventEnvelope {
14533    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14534        record.environment_profile = None;
14535    }
14536    envelope
14537}
14538
14539fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14540    let contains_environment_profile = match reply.result.as_ref() {
14541        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14542            receipt.environment_profile.is_some()
14543        }
14544        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14545            receipt.spawn.environment_profile.is_some()
14546        }
14547        _ => false,
14548    };
14549    if contains_environment_profile {
14550        reply.result = Err(failure(
14551            NodeFailureCode::UnsupportedCapability,
14552            "child environment profiles were not negotiated",
14553        ));
14554        return;
14555    }
14556    let Ok(response) = reply.result.as_mut() else {
14557        return;
14558    };
14559    match response {
14560        NodeResponse::Snapshot { snapshot, .. } => {
14561            clear_snapshot_child_environment_profiles(snapshot);
14562        }
14563        NodeResponse::Resync { snapshot, events, .. } => {
14564            clear_snapshot_child_environment_profiles(snapshot);
14565            for event in events {
14566                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14567                    record.environment_profile = None;
14568                }
14569            }
14570        }
14571        NodeResponse::SessionRecordUpdated { record }
14572        | NodeResponse::ProviderSessionIndexed { record }
14573        | NodeResponse::NativeSessionIndexed { record, .. }
14574        | NodeResponse::SessionRecordResumed { record, .. } => {
14575            record.environment_profile = None;
14576        }
14577        NodeResponse::Armed { .. }
14578        | NodeResponse::Spawned { .. }
14579        | NodeResponse::Activated { .. }
14580        | NodeResponse::Aborted { .. }
14581        | NodeResponse::ReplyChunkAccepted { .. }
14582        | NodeResponse::CallRejected { .. }
14583        | NodeResponse::DeliveryStageBegun { .. }
14584        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14585        | NodeResponse::DeliveryCommitted { .. }
14586        | NodeResponse::DeliveryStageAborted { .. }
14587        | NodeResponse::WorkspaceInspected { .. }
14588        | NodeResponse::HostDirectoriesBrowsed { .. }
14589        | NodeResponse::WorkspaceFileRead { .. }
14590        | NodeResponse::WorkspaceFileWritten { .. }
14591        | NodeResponse::WorkspaceFileCreated { .. }
14592        | NodeResponse::WorkspaceDirectoryCreated { .. }
14593        | NodeResponse::GitHistoryRead { .. }
14594        | NodeResponse::GitDiffRead { .. }
14595        | NodeResponse::Controller { .. }
14596        | NodeResponse::SpawnAccepted { .. }
14597        | NodeResponse::SpawnSpecAccepted { .. }
14598        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14599        | NodeResponse::ManagedWorktreeCleanup { .. }
14600        | NodeResponse::NativeSessionsCataloged { .. }
14601        | NodeResponse::NativeSessionsPaged { .. }
14602        | NodeResponse::NativeSessionPreviewed { .. }
14603        | NodeResponse::SessionRecordPreviewed { .. }
14604        | NodeResponse::HistoryDiscovered { .. }
14605        | NodeResponse::HistoryLoaded { .. }
14606        | NodeResponse::ContextPackForSessionRecordExported { .. }
14607        | NodeResponse::ContextPackExported { .. }
14608        | NodeResponse::ContextPackForgotten { .. }
14609        | NodeResponse::DurableContextPackResolved { .. }
14610        | NodeResponse::ContextPackBytesRead { .. }
14611        | NodeResponse::SessionRecordForgotten { .. }
14612        | NodeResponse::WorkspaceRegistered { .. }
14613        | NodeResponse::StandaloneWorkspaceCreated { .. }
14614        | NodeResponse::WorkspaceUnregistered { .. }
14615        | NodeResponse::WorktreeCreated { .. }
14616        | NodeResponse::WorktreeRemoved { .. }
14617        | NodeResponse::Accepted
14618        | NodeResponse::ShuttingDown => {}
14619    }
14620}
14621
14622fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14623    for record in &mut snapshot.session_records {
14624        record.bundle = None;
14625    }
14626}
14627
14628fn project_event_without_session_bundle(
14629    mut envelope: NodeEventEnvelope,
14630) -> NodeEventEnvelope {
14631    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14632        record.bundle = None;
14633    }
14634    envelope
14635}
14636
14637fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14638    let contains_bundle = match reply.result.as_ref() {
14639        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14640        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14641            receipt.spawn.bundle.is_some()
14642        }
14643        _ => false,
14644    };
14645    if contains_bundle {
14646        reply.result = Err(failure(
14647            NodeFailureCode::UnsupportedCapability,
14648            "session bundles were not negotiated",
14649        ));
14650        return;
14651    }
14652    let Ok(response) = reply.result.as_mut() else {
14653        return;
14654    };
14655    match response {
14656        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14657        NodeResponse::Resync { snapshot, events, .. } => {
14658            clear_snapshot_session_bundles(snapshot);
14659            for event in events {
14660                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14661                    record.bundle = None;
14662                }
14663            }
14664        }
14665        NodeResponse::SessionRecordUpdated { record }
14666        | NodeResponse::ProviderSessionIndexed { record }
14667        | NodeResponse::NativeSessionIndexed { record, .. }
14668        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14669        NodeResponse::Armed { .. }
14670        | NodeResponse::Spawned { .. }
14671        | NodeResponse::Activated { .. }
14672        | NodeResponse::Aborted { .. }
14673        | NodeResponse::ReplyChunkAccepted { .. }
14674        | NodeResponse::CallRejected { .. }
14675        | NodeResponse::DeliveryStageBegun { .. }
14676        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14677        | NodeResponse::DeliveryCommitted { .. }
14678        | NodeResponse::DeliveryStageAborted { .. }
14679        | NodeResponse::WorkspaceInspected { .. }
14680        | NodeResponse::HostDirectoriesBrowsed { .. }
14681        | NodeResponse::WorkspaceFileRead { .. }
14682        | NodeResponse::WorkspaceFileWritten { .. }
14683        | NodeResponse::WorkspaceFileCreated { .. }
14684        | NodeResponse::WorkspaceDirectoryCreated { .. }
14685        | NodeResponse::GitHistoryRead { .. }
14686        | NodeResponse::GitDiffRead { .. }
14687        | NodeResponse::Controller { .. }
14688        | NodeResponse::SpawnAccepted { .. }
14689        | NodeResponse::SpawnSpecAccepted { .. }
14690        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14691        | NodeResponse::ManagedWorktreeCleanup { .. }
14692        | NodeResponse::NativeSessionsCataloged { .. }
14693        | NodeResponse::NativeSessionsPaged { .. }
14694        | NodeResponse::NativeSessionPreviewed { .. }
14695        | NodeResponse::SessionRecordPreviewed { .. }
14696        | NodeResponse::HistoryDiscovered { .. }
14697        | NodeResponse::HistoryLoaded { .. }
14698        | NodeResponse::ContextPackForSessionRecordExported { .. }
14699        | NodeResponse::ContextPackExported { .. }
14700        | NodeResponse::ContextPackForgotten { .. }
14701        | NodeResponse::DurableContextPackResolved { .. }
14702        | NodeResponse::ContextPackBytesRead { .. }
14703        | NodeResponse::SessionRecordForgotten { .. }
14704        | NodeResponse::WorkspaceRegistered { .. }
14705        | NodeResponse::StandaloneWorkspaceCreated { .. }
14706        | NodeResponse::WorkspaceUnregistered { .. }
14707        | NodeResponse::WorktreeCreated { .. }
14708        | NodeResponse::WorktreeRemoved { .. }
14709        | NodeResponse::Accepted
14710        | NodeResponse::ShuttingDown => {}
14711    }
14712}
14713
14714fn project_snapshot_history_for_wire(
14715    snapshot: &mut NodeSnapshot,
14716    include_history_metadata: bool,
14717) {
14718    for workspace in &mut snapshot.workspaces {
14719        for session in &mut workspace.sessions {
14720            if include_history_metadata {
14721                session.history.loaded = None;
14722            } else {
14723                session.history = Default::default();
14724            }
14725        }
14726    }
14727}
14728
14729fn project_event_history_for_wire(
14730    mut envelope: NodeEventEnvelope,
14731    include_history_metadata: bool,
14732) -> NodeEventEnvelope {
14733    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14734        for session in &mut workspace.sessions {
14735            if include_history_metadata {
14736                session.history.loaded = None;
14737            } else {
14738                session.history = Default::default();
14739            }
14740        }
14741    }
14742    envelope
14743}
14744
14745fn project_response_history_for_wire(
14746    reply: &mut ResponseEnvelope,
14747    include_history_metadata: bool,
14748) {
14749    let Ok(response) = reply.result.as_mut() else {
14750        return;
14751    };
14752    match response {
14753        NodeResponse::Snapshot { snapshot, .. } => {
14754            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14755        }
14756        NodeResponse::Resync {
14757            snapshot, events, ..
14758        } => {
14759            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14760            for event in events {
14761                *event = project_event_history_for_wire(
14762                    event.clone(),
14763                    include_history_metadata,
14764                );
14765            }
14766        }
14767        NodeResponse::WorkspaceRegistered { workspace }
14768        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14769        | NodeResponse::WorktreeCreated { workspace, .. } => {
14770            for session in &mut workspace.sessions {
14771                if include_history_metadata {
14772                    session.history.loaded = None;
14773                } else {
14774                    session.history = Default::default();
14775                }
14776            }
14777        }
14778        _ => {}
14779    }
14780}
14781
14782fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14783    for record in &mut snapshot.session_records {
14784        record.context_id = None;
14785        record.context = None;
14786        record.exported_context = None;
14787    }
14788}
14789
14790fn project_event_without_context_pack(
14791    mut envelope: NodeEventEnvelope,
14792) -> NodeEventEnvelope {
14793    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14794        record.context_id = None;
14795        record.context = None;
14796        record.exported_context = None;
14797    }
14798    envelope
14799}
14800
14801fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14802    let contains_context = match reply.result.as_ref() {
14803        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14804        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14805            receipt.spawn.context.is_some()
14806        }
14807        Ok(NodeResponse::HistoryDiscovered { .. })
14808        | Ok(NodeResponse::HistoryLoaded { .. })
14809        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14810        | Ok(NodeResponse::ContextPackExported { .. })
14811        | Ok(NodeResponse::ContextPackForgotten { .. })
14812        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14813        _ => false,
14814    };
14815    if contains_context {
14816        reply.result = Err(failure(
14817            NodeFailureCode::UnsupportedCapability,
14818            "history context packs were not negotiated",
14819        ));
14820        return;
14821    }
14822    let Ok(response) = reply.result.as_mut() else {
14823        return;
14824    };
14825    match response {
14826        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14827        NodeResponse::Resync {
14828            snapshot, events, ..
14829        } => {
14830            clear_snapshot_context_packs(snapshot);
14831            for event in events {
14832                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14833                    record.context_id = None;
14834                    record.context = None;
14835                    record.exported_context = None;
14836                }
14837            }
14838        }
14839        NodeResponse::SessionRecordUpdated { record }
14840        | NodeResponse::ProviderSessionIndexed { record }
14841        | NodeResponse::NativeSessionIndexed { record, .. }
14842        | NodeResponse::SessionRecordResumed { record, .. } => {
14843            record.context_id = None;
14844            record.context = None;
14845            record.exported_context = None;
14846        }
14847        _ => {}
14848    }
14849}
14850
14851fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14852    let contains_open_record = match reply.result.as_ref() {
14853        Ok(NodeResponse::SessionRecordUpdated { record })
14854        | Ok(NodeResponse::ProviderSessionIndexed { record })
14855        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14856            !provider_id_is_legacy(&record.provider)
14857        }
14858        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14859            !provider_id_is_legacy(&selection.route.provider)
14860                || !provider_id_is_legacy(&record.provider)
14861        }
14862        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14863            !provider_id_is_legacy(&receipt.provider)
14864        }
14865        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14866            !provider_id_is_legacy(&receipt.spawn.provider)
14867        }
14868        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14869        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14870            !provider_id_is_legacy(&route.provider)
14871        }
14872        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14873            !provider_id_is_legacy(&selection.route.provider)
14874        }
14875        _ => false,
14876    };
14877    if contains_open_record {
14878        reply.result = Err(failure(
14879            NodeFailureCode::UnsupportedCapability,
14880            "open provider IDs were not negotiated",
14881        ));
14882        return;
14883    }
14884    let Ok(response) = reply.result.as_mut() else {
14885        return;
14886    };
14887    match response {
14888        NodeResponse::Snapshot { snapshot, .. } => {
14889            project_snapshot_legacy_provider_ids(snapshot);
14890        }
14891        NodeResponse::Resync { snapshot, events, .. } => {
14892            project_snapshot_legacy_provider_ids(snapshot);
14893            *events = std::mem::take(events)
14894                .into_iter()
14895                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14896                .collect();
14897        }
14898        NodeResponse::WorkspaceRegistered { workspace }
14899        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14900        | NodeResponse::WorktreeCreated { workspace, .. } => {
14901            workspace
14902                .sessions
14903                .retain(|session| provider_id_is_legacy(&session.agent_id));
14904        }
14905        NodeResponse::Armed { .. }
14906        | NodeResponse::Spawned { .. }
14907        | NodeResponse::Activated { .. }
14908        | NodeResponse::Aborted { .. }
14909        | NodeResponse::ReplyChunkAccepted { .. }
14910        | NodeResponse::CallRejected { .. }
14911        | NodeResponse::DeliveryStageBegun { .. }
14912        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14913        | NodeResponse::DeliveryCommitted { .. }
14914        | NodeResponse::DeliveryStageAborted { .. }
14915        | NodeResponse::WorkspaceInspected { .. }
14916        | NodeResponse::HostDirectoriesBrowsed { .. }
14917        | NodeResponse::WorkspaceFileRead { .. }
14918        | NodeResponse::WorkspaceFileWritten { .. }
14919        | NodeResponse::WorkspaceFileCreated { .. }
14920        | NodeResponse::WorkspaceDirectoryCreated { .. }
14921        | NodeResponse::GitHistoryRead { .. }
14922        | NodeResponse::GitDiffRead { .. }
14923        | NodeResponse::Controller { .. }
14924        | NodeResponse::SpawnAccepted { .. }
14925        | NodeResponse::SpawnSpecAccepted { .. }
14926        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14927        | NodeResponse::ManagedWorktreeCleanup { .. }
14928        | NodeResponse::NativeSessionsCataloged { .. }
14929        | NodeResponse::NativeSessionsPaged { .. }
14930        | NodeResponse::NativeSessionPreviewed { .. }
14931        | NodeResponse::SessionRecordPreviewed { .. }
14932        | NodeResponse::HistoryDiscovered { .. }
14933        | NodeResponse::HistoryLoaded { .. }
14934        | NodeResponse::ContextPackForSessionRecordExported { .. }
14935        | NodeResponse::ContextPackExported { .. }
14936        | NodeResponse::ContextPackForgotten { .. }
14937        | NodeResponse::DurableContextPackResolved { .. }
14938        | NodeResponse::ContextPackBytesRead { .. }
14939        | NodeResponse::SessionRecordUpdated { .. }
14940        | NodeResponse::ProviderSessionIndexed { .. }
14941        | NodeResponse::NativeSessionIndexed { .. }
14942        | NodeResponse::SessionRecordResumed { .. }
14943        | NodeResponse::SessionRecordForgotten { .. }
14944        | NodeResponse::WorkspaceUnregistered { .. }
14945        | NodeResponse::WorktreeRemoved { .. }
14946        | NodeResponse::Accepted
14947        | NodeResponse::ShuttingDown => {}
14948    }
14949}
14950
14951fn project_event_legacy_provider_ids(
14952    shared: &NodeShared,
14953    mut envelope: NodeEventEnvelope,
14954) -> Option<NodeEventEnvelope> {
14955    let include = match &mut envelope.event {
14956        NodeEvent::HarnessMcpReadCall { .. } => true,
14957        NodeEvent::Control { address, .. }
14958        | NodeEvent::AgentStream { address, .. }
14959        | NodeEvent::TerminalFrame { address, .. } => shared
14960            .validate_address(address)
14961            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14962        NodeEvent::WorkspaceAdded { workspace } => {
14963            workspace
14964                .sessions
14965                .retain(|session| provider_id_is_legacy(&session.agent_id));
14966            true
14967        }
14968        NodeEvent::SessionRecordUpserted { record } => {
14969            provider_id_is_legacy(&record.provider)
14970        }
14971        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14972        NodeEvent::SessionRecordRemoved { .. } => shared
14973            .history
14974            .lock()
14975            .unwrap_or_else(|poisoned| poisoned.into_inner())
14976            .removed_record_providers
14977            .get(&envelope.sequence)
14978            .is_some_and(provider_id_is_legacy),
14979        NodeEvent::ControllerChanged { .. }
14980        | NodeEvent::ManagedWorktreeUpserted { .. }
14981        | NodeEvent::ManagedWorktreeRemoved { .. }
14982        | NodeEvent::WorkspaceRemoved { .. }
14983        | NodeEvent::ResyncRequired { .. } => true,
14984    };
14985    include.then_some(envelope)
14986}
14987
14988fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14989    let Ok(response) = reply.result.as_mut() else {
14990        return;
14991    };
14992    match response {
14993        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14994            snapshot.provider_runtime_statuses.clear();
14995        }
14996        NodeResponse::Armed { .. }
14997        | NodeResponse::Spawned { .. }
14998        | NodeResponse::Activated { .. }
14999        | NodeResponse::Aborted { .. }
15000        | NodeResponse::ReplyChunkAccepted { .. }
15001        | NodeResponse::CallRejected { .. }
15002        | NodeResponse::DeliveryStageBegun { .. }
15003        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15004        | NodeResponse::DeliveryCommitted { .. }
15005        | NodeResponse::DeliveryStageAborted { .. }
15006        | NodeResponse::WorkspaceInspected { .. }
15007        | NodeResponse::HostDirectoriesBrowsed { .. }
15008        | NodeResponse::WorkspaceFileRead { .. }
15009        | NodeResponse::WorkspaceFileWritten { .. }
15010        | NodeResponse::WorkspaceFileCreated { .. }
15011        | NodeResponse::WorkspaceDirectoryCreated { .. }
15012        | NodeResponse::GitHistoryRead { .. }
15013        | NodeResponse::GitDiffRead { .. }
15014        | NodeResponse::Controller { .. }
15015        | NodeResponse::SpawnAccepted { .. }
15016        | NodeResponse::SpawnSpecAccepted { .. }
15017        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15018        | NodeResponse::ManagedWorktreeCleanup { .. }
15019        | NodeResponse::NativeSessionsCataloged { .. }
15020        | NodeResponse::NativeSessionsPaged { .. }
15021        | NodeResponse::NativeSessionPreviewed { .. }
15022        | NodeResponse::SessionRecordPreviewed { .. }
15023        | NodeResponse::HistoryDiscovered { .. }
15024        | NodeResponse::HistoryLoaded { .. }
15025        | NodeResponse::ContextPackForSessionRecordExported { .. }
15026        | NodeResponse::ContextPackExported { .. }
15027        | NodeResponse::ContextPackForgotten { .. }
15028        | NodeResponse::DurableContextPackResolved { .. }
15029        | NodeResponse::ContextPackBytesRead { .. }
15030        | NodeResponse::SessionRecordUpdated { .. }
15031        | NodeResponse::ProviderSessionIndexed { .. }
15032        | NodeResponse::NativeSessionIndexed { .. }
15033        | NodeResponse::SessionRecordResumed { .. }
15034        | NodeResponse::SessionRecordForgotten { .. }
15035        | NodeResponse::WorkspaceRegistered { .. }
15036        | NodeResponse::StandaloneWorkspaceCreated { .. }
15037        | NodeResponse::WorkspaceUnregistered { .. }
15038        | NodeResponse::WorktreeCreated { .. }
15039        | NodeResponse::WorktreeRemoved { .. }
15040        | NodeResponse::Accepted
15041        | NodeResponse::ShuttingDown => {}
15042    }
15043}
15044
15045struct ControllerReleaseGuard {
15046    shared: Arc<NodeShared>,
15047    connection_id: u64,
15048}
15049
15050impl Drop for ControllerReleaseGuard {
15051    fn drop(&mut self) {
15052        self.shared.release_controller(self.connection_id);
15053    }
15054}
15055
15056struct AbortTaskOnDrop(AbortHandle);
15057
15058impl Drop for AbortTaskOnDrop {
15059    fn drop(&mut self) {
15060        self.0.abort();
15061    }
15062}
15063
15064/// Reads an unsigned env-config override, following the pattern of every
15065/// other bounded knob in this module: missing/unparsable/zero falls back to
15066/// `default`, and the resolved value is always clamped to `max` so a bad
15067/// override cannot reopen a bound this budget exists to enforce.
15068fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15069    std::env::var(name)
15070        .ok()
15071        .and_then(|value| value.parse::<u64>().ok())
15072        .filter(|value| *value > 0)
15073        .unwrap_or(default)
15074        .min(max)
15075}
15076
15077fn workspace_inspection_time_budget_ms() -> u64 {
15078    env_config_bounded(
15079        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15080        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15081        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15082    )
15083}
15084
15085fn workspace_inspection_entry_cap() -> usize {
15086    env_config_bounded(
15087        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15088        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15089        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15090    ) as usize
15091}
15092
15093/// Tracks the shared walk-side budget across the whole recursive walk: a
15094/// wall-clock deadline (shared with the git phase that runs after the walk)
15095/// and a cap on directory entries visited, whether or not they were pushed
15096/// to the response. Cheap to check per entry — one `Instant::now()` and one
15097/// integer compare.
15098struct WorkspaceWalkBudget {
15099    deadline: Instant,
15100    entry_cap: usize,
15101    entries_visited: u64,
15102    time_budget_exceeded: bool,
15103    entry_cap_exceeded: bool,
15104}
15105
15106impl WorkspaceWalkBudget {
15107    fn new(deadline: Instant, entry_cap: usize) -> Self {
15108        Self {
15109            deadline,
15110            entry_cap,
15111            entries_visited: 0,
15112            time_budget_exceeded: false,
15113            entry_cap_exceeded: false,
15114        }
15115    }
15116
15117    fn is_exhausted(&self) -> bool {
15118        self.time_budget_exceeded || self.entry_cap_exceeded
15119    }
15120
15121    /// Records one visited directory entry (pushed to the response or not)
15122    /// and reports whether the walk must stop now.
15123    fn record_visit(&mut self) -> bool {
15124        self.entries_visited += 1;
15125        if self.entries_visited as usize >= self.entry_cap {
15126            self.entry_cap_exceeded = true;
15127        }
15128        if Instant::now() >= self.deadline {
15129            self.time_budget_exceeded = true;
15130        }
15131        self.is_exhausted()
15132    }
15133}
15134
15135fn collect_workspace_entries(
15136    root: &Path,
15137    deadline: Instant,
15138    entry_cap: usize,
15139) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15140    let mut entries = Vec::new();
15141    let mut truncated = false;
15142    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15143    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15144    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15145    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15146    // require strict global byte order over relative paths.
15147    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15148    (entries, truncated, budget)
15149}
15150
15151/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15152/// `inspect_workspace` response — `None` once the walk and git phase both
15153/// completed inside their shared budget.
15154fn workspace_inspection_truncation(
15155    walk_budget: &WorkspaceWalkBudget,
15156    git_time_budget_exceeded: bool,
15157    elapsed: Duration,
15158) -> Option<WorkspaceInspectionTruncationV1> {
15159    if !walk_budget.time_budget_exceeded
15160        && !walk_budget.entry_cap_exceeded
15161        && !git_time_budget_exceeded
15162    {
15163        return None;
15164    }
15165    Some(WorkspaceInspectionTruncationV1 {
15166        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15167        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15168        git_time_budget_exceeded,
15169        entries_visited: walk_budget.entries_visited,
15170        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15171    })
15172}
15173
15174fn walk_workspace_directory(
15175    directory: &Path,
15176    relative_directory: Option<&RepositoryPath>,
15177    depth: usize,
15178    entries: &mut Vec<WorkspaceEntry>,
15179    truncated: &mut bool,
15180    budget: &mut WorkspaceWalkBudget,
15181) {
15182    if budget.is_exhausted() {
15183        *truncated = true;
15184        return;
15185    }
15186    let read_dir = match std::fs::read_dir(directory) {
15187        Ok(read_dir) => read_dir,
15188        Err(_) => {
15189            *truncated = true;
15190            return;
15191        }
15192    };
15193    let mut children = Vec::new();
15194    for child in read_dir {
15195        match child {
15196            Ok(child) => children.push(child),
15197            Err(_) => *truncated = true,
15198        }
15199    }
15200    children.sort_by_key(|child| child.file_name());
15201    for child in children {
15202        if budget.record_visit() {
15203            *truncated = true;
15204            return;
15205        }
15206        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15207            *truncated = true;
15208            return;
15209        }
15210        let file_type = match child.file_type() {
15211            Ok(file_type) => file_type,
15212            Err(_) => {
15213                *truncated = true;
15214                continue;
15215            }
15216        };
15217        if file_type.is_symlink() {
15218            continue;
15219        }
15220        let name = match child.file_name().into_string() {
15221            Ok(name) => name,
15222            Err(_) => {
15223                *truncated = true;
15224                continue;
15225            }
15226        };
15227        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15228            continue;
15229        }
15230        let relative_path = match relative_directory {
15231            None => windows_repository_path(name),
15232            Some(parent) => parent
15233                .as_utf8()
15234                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15235        };
15236        let Some(relative_path) = relative_path else {
15237            *truncated = true;
15238            continue;
15239        };
15240        let kind = if file_type.is_dir() {
15241            WorkspaceEntryKind::Directory
15242        } else if file_type.is_file() {
15243            WorkspaceEntryKind::File
15244        } else {
15245            continue;
15246        };
15247        entries.push(WorkspaceEntry {
15248            relative_path: relative_path.clone(),
15249            kind,
15250        });
15251        if file_type.is_dir() {
15252            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15253                walk_workspace_directory(
15254                    &child.path(),
15255                    Some(&relative_path),
15256                    depth + 1,
15257                    entries,
15258                    truncated,
15259                    budget,
15260                );
15261            } else {
15262                *truncated = true;
15263            }
15264        }
15265    }
15266}
15267
15268fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15269    if value.contains('\\') {
15270        return None;
15271    }
15272    RepositoryPath::utf8(value).ok()
15273}
15274
15275fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15276    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15277}
15278
15279/// Excludes a directory from the workspace tree walk. The three literal
15280/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15281/// beneath them catches every build-output directory regardless of name
15282/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15283/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15284/// directory without maintaining a name heuristic list). The name check
15285/// runs first and returns early so a directory rejected by name never pays
15286/// for the extra `CACHEDIR.TAG` stat.
15287fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15288    if name.eq_ignore_ascii_case(".git")
15289        || name.eq_ignore_ascii_case("target")
15290        || name.eq_ignore_ascii_case("node_modules")
15291    {
15292        return true;
15293    }
15294    path.join("CACHEDIR.TAG").is_file()
15295}
15296
15297fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15298    matches!(
15299        status,
15300        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15301    )
15302}
15303
15304#[derive(Clone, Debug, Eq, PartialEq)]
15305enum ContextPackRepositoryHead {
15306    NotRepository,
15307    Unborn,
15308    Commit(String),
15309}
15310
15311#[derive(Clone, Debug, Eq, PartialEq)]
15312struct ContextPackRepositoryObservation {
15313    head: ContextPackRepositoryHead,
15314    git: GitSnapshot,
15315    files: Vec<ContextPackRepositoryFileSource>,
15316}
15317
15318fn stable_context_pack_repository(
15319    first: ContextPackRepositoryObservation,
15320    second: ContextPackRepositoryObservation,
15321) -> Result<ContextPackRepository, NodeFailure> {
15322    if first != second {
15323        return Err(failure(
15324            NodeFailureCode::ContextPackMaterializationFailed,
15325            "context repository changed during bounded capture",
15326        ));
15327    }
15328    Ok(ContextPackRepository::from_sources(
15329        &second.git,
15330        second.files,
15331    ))
15332}
15333
15334/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15335/// (and only once) the shared walk+git `deadline` has already passed. Every
15336/// remaining phase in `inspect_git_workspace` checks this immediately
15337/// before issuing its own git call, so a budget spent by the walk (or by an
15338/// earlier git call) skips the rest of the chain instead of paying for it
15339/// anyway.
15340fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15341    if Instant::now() < deadline {
15342        return false;
15343    }
15344    append_git_diagnostic(
15345        snapshot,
15346        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15347    );
15348    true
15349}
15350
15351/// Deadline-aware sibling of `run_git_bounded`, used only by
15352/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15353/// whatever remains of the shared `deadline` (never more than
15354/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15355/// of the budget cannot still hand a full per-call timeout to every git
15356/// probe behind it. `inspect_git_workspace` already guards each call site
15357/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15358/// passed yet here.
15359async fn run_git_bounded_with_deadline(
15360    root: &str,
15361    arguments: &[&str],
15362    output_limit: usize,
15363    deadline: Instant,
15364) -> io::Result<GitCommandOutput> {
15365    let remaining = deadline.checked_duration_since(Instant::now());
15366    let timeout_ms = remaining
15367        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15368        .unwrap_or(0)
15369        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15370    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15371}
15372
15373/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15374/// `deadline` is the same absolute instant the preceding directory walk was
15375/// bounded by, so time already spent walking counts against what remains
15376/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15377/// shared budget (not an individual per-call timeout) is what cut the git
15378/// phase short.
15379async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15380    let mut snapshot = GitSnapshot {
15381        is_repository: false,
15382        branch: None,
15383        status: Vec::new(),
15384        recent_commits: Vec::new(),
15385        worktrees: Vec::new(),
15386        managed_worktree: None,
15387        truncated: false,
15388        diagnostic: None,
15389    };
15390    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15391        return (snapshot, true);
15392    }
15393    let repository = match run_git_bounded_with_deadline(
15394        root,
15395        &["rev-parse", "--is-inside-work-tree"],
15396        4 * 1_024,
15397        deadline,
15398    )
15399    .await
15400    {
15401        Ok(output) => output,
15402        Err(error) => {
15403            append_git_diagnostic(
15404                &mut snapshot,
15405                &format!("git inspection unavailable: {error}"),
15406            );
15407            return (snapshot, false);
15408        }
15409    };
15410    snapshot.truncated |= repository.truncated;
15411    if repository.timed_out {
15412        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15413        return (snapshot, false);
15414    }
15415    if !repository.success
15416        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15417    {
15418        let stderr = String::from_utf8_lossy(&repository.stderr);
15419        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15420            append_git_diagnostic(&mut snapshot, stderr.trim());
15421        }
15422        return (snapshot, false);
15423    }
15424    snapshot.is_repository = true;
15425
15426    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15427        return (snapshot, true);
15428    }
15429    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15430        Ok(output) => {
15431            snapshot.truncated |= output.truncated;
15432            if output.timed_out {
15433                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15434            } else if output.success {
15435                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15436                if !branch.is_empty() {
15437                    snapshot.branch = Some(truncate_git_field(
15438                        &branch,
15439                        MAX_REPOSITORY_PATH_BYTES,
15440                        &mut snapshot.truncated,
15441                    ));
15442                }
15443            }
15444        }
15445        Err(error) => append_git_diagnostic(
15446            &mut snapshot,
15447            &format!("git branch query failed: {error}"),
15448        ),
15449    }
15450    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15451        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15452            snapshot.truncated |= output.truncated;
15453            if output.success && !output.timed_out {
15454                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15455                if !head.is_empty() {
15456                    snapshot.branch = Some(format!("detached:{head}"));
15457                }
15458            }
15459        }
15460    }
15461
15462    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15463        return (snapshot, true);
15464    }
15465    match run_git_bounded_with_deadline(
15466        root,
15467        &[
15468            "status",
15469            "--porcelain=v1",
15470            "-z",
15471            "--renames",
15472            "--untracked-files=normal",
15473            "--",
15474            ".",
15475        ],
15476        GIT_OUTPUT_MAX_BYTES,
15477        deadline,
15478    )
15479    .await
15480    {
15481        Ok(output) => {
15482            snapshot.truncated |= output.truncated;
15483            if output.timed_out {
15484                append_git_diagnostic(&mut snapshot, "git status timed out");
15485            } else if output.success {
15486                parse_git_status(&output.stdout, &mut snapshot);
15487            } else {
15488                append_git_diagnostic(
15489                    &mut snapshot,
15490                    String::from_utf8_lossy(&output.stderr).trim(),
15491                );
15492            }
15493        }
15494        Err(error) => append_git_diagnostic(
15495            &mut snapshot,
15496            &format!("git status failed: {error}"),
15497        ),
15498    }
15499
15500    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15501        return (snapshot, true);
15502    }
15503    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15504    match run_git_bounded_with_deadline(
15505        root,
15506        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15507        16 * 1_024,
15508        deadline,
15509    )
15510    .await
15511    {
15512        Ok(output) => {
15513            snapshot.truncated |= output.truncated;
15514            if output.timed_out {
15515                append_git_diagnostic(&mut snapshot, "git log timed out");
15516            } else if output.success {
15517                parse_git_commits(&output.stdout, &mut snapshot);
15518            } else {
15519                let stderr = String::from_utf8_lossy(&output.stderr);
15520                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15521                    append_git_diagnostic(&mut snapshot, stderr.trim());
15522                }
15523            }
15524        }
15525        Err(error) => append_git_diagnostic(
15526            &mut snapshot,
15527            &format!("git log failed: {error}"),
15528        ),
15529    }
15530
15531    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15532        return (snapshot, true);
15533    }
15534    match list_git_worktrees_with_deadline(root, deadline).await {
15535        Ok(worktrees) => {
15536            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15537        }
15538        Err(error) => append_git_diagnostic(
15539            &mut snapshot,
15540            &format!("git worktree list failed: {}", error.message),
15541        ),
15542    }
15543    (snapshot, false)
15544}
15545
15546fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15547    let mut remaining = output;
15548    while !remaining.is_empty() {
15549        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15550            snapshot.truncated = true;
15551            break;
15552        }
15553        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15554            snapshot.truncated = true;
15555            break;
15556        };
15557        let record = &remaining[..record_end];
15558        remaining = &remaining[record_end + 1..];
15559        let has_previous_path = record
15560            .get(..2)
15561            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15562        let previous_path_bytes = if has_previous_path {
15563            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15564                snapshot.truncated = true;
15565                break;
15566            };
15567            let previous = &remaining[..previous_end];
15568            remaining = &remaining[previous_end + 1..];
15569            Some(previous)
15570        } else {
15571            None
15572        };
15573        if record.len() < 4
15574            || record[2] != b' '
15575            || !is_git_status_code(record[0])
15576            || !is_git_status_code(record[1])
15577        {
15578            snapshot.truncated = true;
15579            continue;
15580        }
15581        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15582            snapshot.truncated = true;
15583            continue;
15584        };
15585        let previous_path = match previous_path_bytes {
15586            Some(previous) => {
15587                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15588                    snapshot.truncated = true;
15589                    continue;
15590                };
15591                Some(previous)
15592            }
15593            None => None,
15594        };
15595        snapshot.status.push(GitStatusEntry {
15596            index_status: (record[0] as char).to_string(),
15597            worktree_status: (record[1] as char).to_string(),
15598            path,
15599            previous_path,
15600        });
15601    }
15602}
15603
15604fn is_git_status_code(byte: u8) -> bool {
15605    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15606}
15607
15608fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15609    for line in String::from_utf8_lossy(output).lines() {
15610        let Some((id, summary)) = line.split_once('\u{1f}') else {
15611            continue;
15612        };
15613        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15614            snapshot.truncated = true;
15615            break;
15616        }
15617        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15618        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15619        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15620    }
15621}
15622
15623fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15624    if value.len() <= max_bytes {
15625        return value.to_owned();
15626    }
15627    *truncated = true;
15628    let mut boundary = max_bytes;
15629    while boundary > 0 && !value.is_char_boundary(boundary) {
15630        boundary -= 1;
15631    }
15632    value[..boundary].to_owned()
15633}
15634
15635fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15636    if message.is_empty() {
15637        return;
15638    }
15639    let normalized = message
15640        .chars()
15641        .map(|character| if character.is_control() { ' ' } else { character })
15642        .collect::<String>();
15643    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15644    if !diagnostic.is_empty() {
15645        diagnostic.push_str("; ");
15646    }
15647    diagnostic.push_str(normalized.trim());
15648    snapshot.diagnostic = Some(truncate_git_field(
15649        &diagnostic,
15650        GIT_DIAGNOSTIC_MAX_BYTES,
15651        &mut snapshot.truncated,
15652    ));
15653}
15654
15655async fn run_git_bounded(
15656    root: &str,
15657    arguments: &[&str],
15658    output_limit: usize,
15659) -> io::Result<GitCommandOutput> {
15660    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15661}
15662
15663async fn read_git_history_bounded(
15664    root: &str,
15665    path: Option<&RepositoryPath>,
15666    before: Option<&GitObjectId>,
15667    limit: u16,
15668) -> Result<GitHistoryPage, NodeFailure> {
15669    let path = path
15670        .map(|path| {
15671            path.as_utf8().ok_or_else(|| {
15672                failure(
15673                    NodeFailureCode::InvalidRepositoryPath,
15674                    "git history path must use UTF-8",
15675                )
15676            })
15677        })
15678        .transpose()?
15679        .unwrap_or(".");
15680    let count = (usize::from(limit) + 1).to_string();
15681    let start = before
15682        .map(|revision| format!("{}^", revision.as_str()))
15683        .unwrap_or_else(|| "HEAD".to_owned());
15684    let arguments = [
15685        "--no-pager",
15686        "log",
15687        "--no-decorate",
15688        "--date=iso-strict",
15689        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15690        "-n",
15691        count.as_str(),
15692        start.as_str(),
15693        "--",
15694        path,
15695    ];
15696    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15697        .await
15698        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15699    if output.timed_out {
15700        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15701    }
15702    if !output.success {
15703        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15704    }
15705    let mut commits = parse_git_history(&output.stdout);
15706    let has_more = commits.len() > usize::from(limit) || output.truncated;
15707    commits.truncate(usize::from(limit));
15708    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15709    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15710}
15711
15712fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15713    String::from_utf8_lossy(output)
15714        .split('\u{1e}')
15715        .filter_map(|record| {
15716            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15717            if fields.len() != 9 {
15718                return None;
15719            }
15720            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15721            let parents = if fields[1].is_empty() {
15722                Vec::new()
15723            } else {
15724                fields[1]
15725                    .split(' ')
15726                    .map(|value| GitObjectId::new(value.to_owned()))
15727                    .collect::<Result<Vec<_>, _>>()
15728                    .ok()?
15729            };
15730            Some(GitCommitDetails {
15731                id,
15732                parents,
15733                subject: bounded_git_text(fields[2], 512),
15734                author_name: bounded_git_text(fields[3], 256),
15735                author_email: bounded_git_text(fields[4], 320),
15736                authored_at: bounded_git_text(fields[5], 64),
15737                committer_name: bounded_git_text(fields[6], 256),
15738                committer_email: bounded_git_text(fields[7], 320),
15739                committed_at: bounded_git_text(fields[8], 64),
15740                signature_status: GitSignatureStatus::NoSignature,
15741                signer: None,
15742            })
15743        })
15744        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15745        .collect()
15746}
15747
15748fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15749    let normalized = value
15750        .chars()
15751        .map(|character| if character.is_control() { ' ' } else { character })
15752        .collect::<String>();
15753    if normalized.len() <= max_bytes {
15754        return normalized;
15755    }
15756    let mut boundary = max_bytes;
15757    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15758        boundary -= 1;
15759    }
15760    normalized[..boundary].to_owned()
15761}
15762
15763async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15764    let path = request.path.as_ref().map(|path| {
15765        path.as_utf8()
15766            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15767    }).transpose()?;
15768    let mut arguments = match &request.mode {
15769        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15770        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15771        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15772    };
15773    if let GitDiffMode::Commit { revision } = &request.mode {
15774        arguments.push(revision.as_str());
15775    }
15776    arguments.push("--");
15777    if let Some(path) = path {
15778        arguments.push(path);
15779    }
15780    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15781        .await
15782        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15783    if output.timed_out {
15784        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15785    }
15786    if !output.success {
15787        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15788    }
15789    Ok(GitDiff {
15790        mode: request.mode,
15791        path: request.path,
15792        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15793        truncated: output.truncated,
15794    })
15795}
15796
15797async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15798    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15799    ResponseEnvelope { request_id: envelope.request_id, result }
15800}
15801
15802async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15803    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15804        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15805    }
15806    let read_only = matches!(
15807        &request,
15808        NodeRequest::Snapshot
15809            | NodeRequest::Resync { .. }
15810            | NodeRequest::BrowseHostDirectories { .. }
15811            | NodeRequest::InspectWorkspace { .. }
15812            | NodeRequest::ReadWorkspaceFile { .. }
15813            | NodeRequest::ReadGitHistory { .. }
15814            | NodeRequest::ReadGitDiff { .. }
15815            | NodeRequest::CatalogNativeSessions { .. }
15816            | NodeRequest::PageNativeSessions { .. }
15817            | NodeRequest::PreviewNativeSession { .. }
15818            | NodeRequest::PreviewSessionRecord { .. }
15819    );
15820    let _mutation_guard = if read_only {
15821        None
15822    } else {
15823        let guard = shared.mutation_gate.lock().await;
15824        if shared.shutdown.load(Ordering::Acquire) {
15825            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15826        }
15827        Some(guard)
15828    };
15829    match request {
15830        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15831            event_sequence: shared.current_sequence(),
15832            controller: shared.controller_state(),
15833            snapshot: shared.snapshot(),
15834        }),
15835        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15836        NodeRequest::ArmHarnessMcpReservation {
15837            reservation_id,
15838            activation_digest,
15839            spawn_spec,
15840            launch,
15841            expires_at_unix_ms,
15842        } => {
15843            shared.require_controller(connection_id, role)?;
15844            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15845                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15846            })?;
15847            let expires_at_unix_ms = registry
15848                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15849                .await
15850                .map_err(harness_mcp_failure)?;
15851            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15852        }
15853        NodeRequest::SpawnSpecWithHarnessMcp {
15854            reservation_id,
15855            activation_digest,
15856            spec,
15857            deadline_unix_ms,
15858        } => {
15859            shared.require_controller(connection_id, role)?;
15860            let receipt = shared
15861                .spawn_from_spec_with_harness_mcp(
15862                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15863                )
15864                .await?;
15865            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15866        }
15867        NodeRequest::ActivateHarnessMcpReservation {
15868            reservation_id,
15869            activation_digest,
15870            record_id,
15871            session,
15872        } => {
15873            shared.require_controller(connection_id, role)?;
15874            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15875                current.instance_id == session.session.instance_id
15876                    && current.generation == session.session.generation
15877            }).and_then(|current| current.process_id).ok_or_else(|| {
15878                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15879            })?;
15880            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15881                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15882            })?.activate(
15883                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15884            ).map_err(harness_mcp_failure)?;
15885            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15886        }
15887        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15888            shared.require_controller(connection_id, role)?;
15889            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15890                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15891            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15892            if let Some(instance_id) = instance_id {
15893                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15894            }
15895            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15896        }
15897        NodeRequest::PutHarnessMcpReplyChunk {
15898            reservation_id, activation_digest, record_id, session, call_id,
15899            offset, final_chunk, chunk_hex,
15900        } => {
15901            shared.require_controller(connection_id, role)?;
15902            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15903                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15904            })?.put_reply_chunk(
15905                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15906                offset, final_chunk, &chunk_hex,
15907            ).map_err(harness_mcp_failure)?;
15908            Ok(NodeResponse::ReplyChunkAccepted {
15909                reservation_id, activation_digest, record_id, session, call_id,
15910                next_offset, completed,
15911            })
15912        }
15913        NodeRequest::RejectHarnessMcpCall {
15914            reservation_id, activation_digest, record_id, session, call_id, reason,
15915        } => {
15916            shared.require_controller(connection_id, role)?;
15917            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15918                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15919            })?.reject_call(
15920                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15921            ).map_err(harness_mcp_failure)?;
15922            Ok(NodeResponse::CallRejected {
15923                reservation_id, activation_digest, record_id, session, call_id,
15924            })
15925        }
15926        NodeRequest::BeginDeliveryStage { manifest } => {
15927            shared.require_controller(connection_id, role)?;
15928            shared.begin_delivery_stage(manifest)
15929        }
15930        NodeRequest::PutDeliveryBlobChunk {
15931            stage_id,
15932            blob_digest,
15933            offset,
15934            chunk_hex,
15935        } => {
15936            shared.require_controller(connection_id, role)?;
15937            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15938        }
15939        NodeRequest::CommitDeliveryStage { stage_id } => {
15940            shared.require_controller(connection_id, role)?;
15941            let receipt = shared.commit_delivery_stage(stage_id)?;
15942            Ok(NodeResponse::DeliveryCommitted { receipt })
15943        }
15944        NodeRequest::AbortDeliveryStage { stage_id } => {
15945            shared.require_controller(connection_id, role)?;
15946            shared.abort_delivery_stage(&stage_id)?;
15947            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15948        }
15949        NodeRequest::BrowseHostDirectories { directory, after } => {
15950            let listing = shared.browse_host_directories(directory, after).await?;
15951            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15952        }
15953        NodeRequest::InspectWorkspace { workspace_id } => {
15954            let inspection = shared.inspect_workspace(workspace_id).await?;
15955            Ok(NodeResponse::WorkspaceInspected { inspection })
15956        }
15957        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15958            let file = shared.read_workspace_file(workspace_id, path).await?;
15959            Ok(NodeResponse::WorkspaceFileRead { file })
15960        }
15961        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15962            shared.require_controller(connection_id, role)?;
15963            let file = shared
15964                .write_workspace_file(workspace_id, path, expected_revision, text)
15965                .await?;
15966            Ok(NodeResponse::WorkspaceFileWritten { file })
15967        }
15968        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15969            shared.require_controller(connection_id, role)?;
15970            let file = shared.create_workspace_file(workspace_id, path).await?;
15971            Ok(NodeResponse::WorkspaceFileCreated { file })
15972        }
15973        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15974            shared.require_controller(connection_id, role)?;
15975            let entry = shared
15976                .create_workspace_directory(workspace_id.clone(), path)
15977                .await?;
15978            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15979        }
15980        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15981            if role != ClientRole::Operator {
15982                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15983            }
15984            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15985                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15986            }
15987            let page = shared
15988                .read_git_history(workspace_id.clone(), path, before, limit)
15989                .await?;
15990            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15991        }
15992        NodeRequest::ReadGitDiff { workspace_id, request } => {
15993            if role != ClientRole::Operator {
15994                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15995            }
15996            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15997            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15998        }
15999        NodeRequest::CatalogNativeSessions { route, limit } => {
16000            if role != ClientRole::Operator {
16001                return Err(failure(
16002                    NodeFailureCode::ObserverReadOnly,
16003                    "native session catalog requires an operator connection",
16004                ));
16005            }
16006            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16007                return Err(failure(
16008                    NodeFailureCode::InvalidRequest,
16009                    "native session catalog limit is invalid",
16010                ));
16011            }
16012            let (entries, summary) = shared
16013                .catalog_native_sessions(route.clone(), limit)
16014                .await?;
16015            Ok(NodeResponse::NativeSessionsCataloged {
16016                route,
16017                entries,
16018                summary: Some(summary),
16019            })
16020        }
16021        NodeRequest::PageNativeSessions {
16022            route,
16023            window,
16024            catalog_revision,
16025            recent_cutoff_unix_ms,
16026            after_selection_id,
16027            limit,
16028        } => {
16029            if role != ClientRole::Operator {
16030                return Err(failure(
16031                    NodeFailureCode::ObserverReadOnly,
16032                    "native session catalog paging requires an operator connection",
16033                ));
16034            }
16035            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16036                || after_selection_id
16037                    .as_deref()
16038                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16039            {
16040                return Err(failure(
16041                    NodeFailureCode::InvalidRequest,
16042                    "native session catalog page request is invalid",
16043                ));
16044            }
16045            let page = shared
16046                .page_native_sessions(
16047                    route.clone(),
16048                    window,
16049                    catalog_revision,
16050                    recent_cutoff_unix_ms,
16051                    after_selection_id,
16052                    limit,
16053                )
16054                .await?;
16055            Ok(NodeResponse::NativeSessionsPaged {
16056                route,
16057                page,
16058            })
16059        }
16060        NodeRequest::PreviewNativeSession {
16061            selection,
16062            message_limit,
16063        } => {
16064            if role != ClientRole::Operator {
16065                return Err(failure(
16066                    NodeFailureCode::ObserverReadOnly,
16067                    "native session preview requires an operator connection",
16068                ));
16069            }
16070            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16071                .contains(&message_limit)
16072                || selection.validate().is_err()
16073            {
16074                return Err(failure(
16075                    NodeFailureCode::InvalidRequest,
16076                    "native session preview request is invalid",
16077                ));
16078            }
16079            let preview = shared
16080                .preview_native_session(selection.clone(), message_limit)
16081                .await?;
16082            Ok(NodeResponse::NativeSessionPreviewed {
16083                selection,
16084                preview,
16085            })
16086        }
16087        NodeRequest::IndexNativeSession {
16088            selection,
16089            display_name,
16090        } => {
16091            shared.require_controller(connection_id, role)?;
16092            let record = shared
16093                .index_native_session(selection.clone(), display_name)
16094                .await?;
16095            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16096        }
16097        NodeRequest::PreviewSessionRecord {
16098            record_id,
16099            message_limit,
16100        } => {
16101            if role != ClientRole::Operator {
16102                return Err(failure(
16103                    NodeFailureCode::ObserverReadOnly,
16104                    "native session preview requires an operator connection",
16105                ));
16106            }
16107            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16108                .contains(&message_limit)
16109            {
16110                return Err(failure(
16111                    NodeFailureCode::InvalidRequest,
16112                    "native session preview request is invalid",
16113                ));
16114            }
16115            let record = shared.record(&record_id)?;
16116            let working_directory = shared.workspace_root(&record.workspace_id)?;
16117            if !platform::roots_equal(
16118                &working_directory,
16119                windows_path_text(&record.canonical_root),
16120            ) {
16121                return Err(failure(
16122                    NodeFailureCode::SessionRecordConflict,
16123                    "managed session workspace root changed; refusing to preview another directory",
16124                ));
16125            }
16126            let identity = record.provider_session.clone().ok_or_else(|| {
16127                failure(
16128                    NodeFailureCode::BackendOperationFailed,
16129                    "native session preview is unavailable for this session record",
16130                )
16131            })?;
16132            if identity.key != ProviderSessionKey::SessionId {
16133                return Err(failure(
16134                    NodeFailureCode::BackendOperationFailed,
16135                    "native session preview is unavailable for this session identity",
16136                ));
16137            }
16138            let preview = shared
16139                .preview_native_session_by_session_id(
16140                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16141                )
16142                .await?;
16143            if preview.session_id != identity.id {
16144                return Err(failure(
16145                    NodeFailureCode::SessionRecordConflict,
16146                    "native session preview identity changed while loading",
16147                ));
16148            }
16149            shared.revalidate_session_record_preview(&record, &identity)?;
16150            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16151            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16152                record_id: record_id.clone(),
16153                summary: SessionHistorySummaryV1::from(&preview),
16154            });
16155            Ok(NodeResponse::SessionRecordPreviewed {
16156                record_id,
16157                preview,
16158            })
16159        }
16160        NodeRequest::AcquireController { lease_ms } => {
16161            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16162            Ok(NodeResponse::Controller { controller: Some(controller) })
16163        }
16164        NodeRequest::ReleaseController => {
16165            shared.release_controller(connection_id);
16166            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16167        }
16168        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16169            shared.require_controller(connection_id, role)?;
16170            let root = require_windows_path(root)?;
16171            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16172            let workspace = shared.register_workspace(workspace_id, root).await?;
16173            Ok(NodeResponse::WorkspaceRegistered { workspace })
16174        }
16175        NodeRequest::CreateStandaloneWorkspace {
16176            workspace_id,
16177            root,
16178            initial_branch,
16179        } => {
16180            shared.require_controller(connection_id, role)?;
16181            let root = require_windows_path(root)?;
16182            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16183            let workspace = shared
16184                .create_standalone_workspace(workspace_id, root, initial_branch)
16185                .await?;
16186            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16187        }
16188        NodeRequest::UnregisterWorkspace { workspace_id } => {
16189            shared.require_controller(connection_id, role)?;
16190            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16191            shared.unregister_workspace(&workspace_id)?;
16192            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16193        }
16194        NodeRequest::CreateWorktree {
16195            source_workspace_id,
16196            workspace_id,
16197            target_root,
16198            branch,
16199            base,
16200        } => {
16201            shared.require_controller(connection_id, role)?;
16202            let target_root = require_windows_path(target_root)?;
16203            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16204            let (worktree, workspace) = shared
16205                .create_worktree(
16206                    source_workspace_id,
16207                    workspace_id,
16208                    target_root,
16209                    branch,
16210                    base,
16211                )
16212                .await?;
16213            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16214        }
16215        NodeRequest::RemoveWorktree {
16216            source_workspace_id,
16217            target_root,
16218        } => {
16219            shared.require_controller(connection_id, role)?;
16220            let native_target_root = require_windows_path(target_root.clone())?;
16221            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16222            let workspace_id = shared
16223                .remove_worktree(source_workspace_id, native_target_root)
16224                .await?;
16225            Ok(NodeResponse::WorktreeRemoved {
16226                target_root,
16227                workspace_id,
16228            })
16229        }
16230        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16231            shared.require_controller(connection_id, role)?;
16232            if !terminal_size.is_valid() {
16233                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16234            }
16235            if let Some(prompt) = initial_prompt.as_deref() {
16236                validate_node_text("spawn initial prompt", prompt)?;
16237            }
16238            match shared
16239                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16240                .await
16241            {
16242                Ok(session) => {
16243                    shared.mark_spawn_provisional(session.clone());
16244                    Ok(NodeResponse::SpawnAccepted { session })
16245                }
16246                Err(err) => {
16247                    tracing::warn!(
16248                        %provider,
16249                        %workspace_id,
16250                        ?mode,
16251                        code = ?err.code,
16252                        message = %err.message,
16253                        "session spawn refused"
16254                    );
16255                    Err(err)
16256                }
16257            }
16258        }
16259        NodeRequest::SpawnSpec { spec } => {
16260            shared.require_controller(connection_id, role)?;
16261            let receipt = shared.spawn_from_spec(spec).await?;
16262            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16263        }
16264        NodeRequest::SpawnManagedWorktree { request } => {
16265            shared.require_controller(connection_id, role)?;
16266            let receipt = shared.spawn_managed_worktree(request).await?;
16267            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16268        }
16269        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16270            shared.require_controller(connection_id, role)?;
16271            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16272            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16273        }
16274        NodeRequest::CleanupManagedWorktree { lease_id } => {
16275            shared.require_controller(connection_id, role)?;
16276            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16277            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16278        }
16279        NodeRequest::DiscoverHistory { session, limit } => {
16280            controlled_session(shared, connection_id, role, &session)?;
16281            let candidates = shared.discover_history(&session, limit).await?;
16282            Ok(NodeResponse::HistoryDiscovered {
16283                session,
16284                candidates,
16285            })
16286        }
16287        NodeRequest::LoadHistory {
16288            session,
16289            candidate_id,
16290        } => {
16291            controlled_session(shared, connection_id, role, &session)?;
16292            let loaded = shared
16293                .load_history(&session, candidate_id)
16294                .await?;
16295            Ok(NodeResponse::HistoryLoaded {
16296                session,
16297                session_id: loaded.session_id,
16298                message_count: loaded.message_count,
16299                completed_turn_count: loaded.completed_turn_count,
16300            })
16301        }
16302        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16303            shared.require_controller(connection_id, role)?;
16304            let context = shared
16305                .export_context_pack_for_session_record(&record_id, &session, false)
16306                .await?;
16307            Ok(NodeResponse::ContextPackForSessionRecordExported {
16308                record_id,
16309                session,
16310                context,
16311            })
16312        }
16313        NodeRequest::ExportContextPack { session } => {
16314            controlled_session(shared, connection_id, role, &session)?;
16315            let context = shared.export_context_pack(&session).await?;
16316            Ok(NodeResponse::ContextPackExported { context })
16317        }
16318        NodeRequest::ForgetContextPack { context_id } => {
16319            shared.require_controller(connection_id, role)?;
16320            shared.forget_context_pack(&context_id)?;
16321            Ok(NodeResponse::ContextPackForgotten { context_id })
16322        }
16323        NodeRequest::ResolveDurableContextPack { context_id } => {
16324            // Read-only, idempotent, no controller lease required: this lets
16325            // a caller preflight "is this durably exported pack still here"
16326            // without holding the Node's mutation controller.
16327            let context = shared
16328                .context_catalog
16329                .read()
16330                .unwrap_or_else(|poisoned| poisoned.into_inner())
16331                .get(&context_id)
16332                .map(|pack| pack.receipt().clone())
16333                .ok_or_else(|| {
16334                    failure(
16335                        NodeFailureCode::UnknownContextPack,
16336                        "context pack is not present",
16337                    )
16338                })?;
16339            Ok(NodeResponse::DurableContextPackResolved { context })
16340        }
16341        NodeRequest::ReadContextPack { digest } => {
16342            // Read-only fetch of the pack's own bytes, keyed by content digest
16343            // rather than the caller's `SpawnContextId`: same no-controller-lease
16344            // rationale as `ResolveDurableContextPack` above, one step further
16345            // down the same catalog.
16346            let (id, byte_len, bytes) = shared
16347                .context_catalog
16348                .read()
16349                .unwrap_or_else(|poisoned| poisoned.into_inner())
16350                .get_by_digest(&digest)
16351                .map(|pack| {
16352                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16353                })
16354                .ok_or_else(|| {
16355                    failure(
16356                        NodeFailureCode::UnknownContextPack,
16357                        &format!("context pack {digest} not on this node"),
16358                    )
16359                })?;
16360            tracing::debug!(
16361                digest = digest.as_str(),
16362                byte_len,
16363                "read context pack bytes",
16364            );
16365            Ok(NodeResponse::ContextPackBytesRead {
16366                pack: ContextPackBytesRead {
16367                    digest,
16368                    id,
16369                    byte_len,
16370                    bytes,
16371                },
16372            })
16373        }
16374        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16375            let provider = controlled_session(shared, connection_id, role, &session)?;
16376            if let Some(prompt) = initial_prompt.as_deref() {
16377                validate_node_text("resume initial prompt", prompt)?;
16378            }
16379            let runtime_requirement = if initial_prompt.is_some() {
16380                ProviderRuntimeRequirement::ResumeWithPrompt
16381            } else {
16382                ProviderRuntimeRequirement::Resume
16383            };
16384            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16385            let runtime_policy = shared
16386                .admit_provider_runtime(&provider, runtime_requirement)
16387                .await?;
16388            let working_directory = shared.bound_workspace_root(&session)?;
16389            let request = ResumeLaunchRequest {
16390                working_directory,
16391                terminal_size,
16392                initial_prompt,
16393            };
16394            request
16395                .validate()
16396                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16397            let command = shared.prepare_command(ControlCommand::Resume {
16398                instance_id: session.session.instance_id,
16399                target: ResumeTarget::CurrentProvider,
16400                runtime_policy,
16401                request,
16402            });
16403            shared.arm_resume(&session, command.id, runtime_policy)?;
16404            let dispatch = shared.dispatch_envelope(command);
16405            if let Err(error) = dispatch {
16406                shared.clear_armed_resume(&session);
16407                return Err(error);
16408            }
16409            Ok(NodeResponse::Accepted)
16410        }
16411        NodeRequest::RenameSessionRecord {
16412            record_id,
16413            display_name,
16414        } => {
16415            shared.require_controller(connection_id, role)?;
16416            let record = shared.rename_session_record(&record_id, display_name)?;
16417            Ok(NodeResponse::SessionRecordUpdated { record })
16418        }
16419        NodeRequest::SetSessionTask {
16420            record_id,
16421            expected_revision,
16422            target,
16423        } => {
16424            shared.require_controller(connection_id, role)?;
16425            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16426            Ok(NodeResponse::SessionRecordUpdated { record })
16427        }
16428        NodeRequest::IndexProviderSession {
16429            workspace_id,
16430            provider,
16431            identity,
16432            display_name,
16433        } => {
16434            shared.require_controller(connection_id, role)?;
16435            let record = shared.index_provider_session(
16436                workspace_id,
16437                provider,
16438                identity,
16439                display_name,
16440            )?;
16441            Ok(NodeResponse::ProviderSessionIndexed { record })
16442        }
16443        NodeRequest::ResumeSessionRecord {
16444            record_id,
16445            terminal_size,
16446            initial_prompt,
16447        } => {
16448            shared.require_controller(connection_id, role)?;
16449            if let Some(prompt) = initial_prompt.as_deref() {
16450                validate_node_text("managed resume initial prompt", prompt)?;
16451            }
16452            let (record, session) = shared
16453                .resume_session_record(
16454                    &record_id,
16455                    terminal_size,
16456                    initial_prompt,
16457                )
16458                .await?;
16459            Ok(NodeResponse::SessionRecordResumed { record, session })
16460        }
16461        NodeRequest::ForgetSessionRecord { record_id } => {
16462            shared.require_controller(connection_id, role)?;
16463            shared.forget_session_record(&record_id).await?;
16464            Ok(NodeResponse::SessionRecordForgotten { record_id })
16465        }
16466        NodeRequest::Prompt { session, text } => {
16467            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16468            validate_node_text("prompt", &text)?;
16469            shared.require_session_runtime_policy(
16470                &session,
16471                ProviderRuntimeRequirement::SemanticPrompt,
16472            )?;
16473            let framing = prompt_framing(&agent_id);
16474            shared
16475                .dispatch_input_bounded(
16476                    &session,
16477                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16478                )
16479                .await?;
16480            Ok(NodeResponse::Accepted)
16481        }
16482        NodeRequest::Paste { session, text } => {
16483            controlled_session(shared, connection_id, role, &session)?;
16484            validate_node_text("paste", &text)?;
16485            shared.require_session_runtime_policy(
16486                &session,
16487                ProviderRuntimeRequirement::SemanticPrompt,
16488            )?;
16489            shared
16490                .dispatch_input_bounded(
16491                    &session,
16492                    InputAction::InsertDraft(PromptPayload {
16493                        text,
16494                        framing: PromptFraming::BracketedPaste,
16495                    }),
16496                )
16497                .await?;
16498            Ok(NodeResponse::Accepted)
16499        }
16500        NodeRequest::Input { session, text } => {
16501            controlled_session(shared, connection_id, role, &session)?;
16502            validate_node_text("terminal input", &text)?;
16503            shared
16504                .dispatch_input_bounded(
16505                    &session,
16506                    InputAction::TerminalText(TerminalText { text }),
16507                )
16508                .await?;
16509            Ok(NodeResponse::Accepted)
16510        }
16511        NodeRequest::TerminalBytes { session, bytes } => {
16512            controlled_session(shared, connection_id, role, &session)?;
16513            shared.sessionize_spawn(session.session.instance_id);
16514            let action = terminal_bytes_action(bytes)?;
16515            shared
16516                .dispatch_input_bounded(&session, action)
16517                .await?;
16518            Ok(NodeResponse::Accepted)
16519        }
16520        NodeRequest::TerminalControl { session, control } => {
16521            controlled_session(shared, connection_id, role, &session)?;
16522            shared
16523                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16524                .await?;
16525            Ok(NodeResponse::Accepted)
16526        }
16527        NodeRequest::Resize { session, size } => {
16528            controlled_session(shared, connection_id, role, &session)?;
16529            shared.sessionize_spawn(session.session.instance_id);
16530            if !size.is_valid() {
16531                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16532            }
16533            shared.dispatch_resize_bounded(&session, size).await?;
16534            Ok(NodeResponse::Accepted)
16535        }
16536        NodeRequest::Interrupt { session } => {
16537            controlled_session(shared, connection_id, role, &session)?;
16538            shared
16539                .dispatch_input_bounded(
16540                    &session,
16541                    InputAction::TerminalControl(TerminalControl::Interrupt),
16542                )
16543                .await?;
16544            Ok(NodeResponse::Accepted)
16545        }
16546        NodeRequest::Stop { session, force } => {
16547            controlled_session(shared, connection_id, role, &session)?;
16548            shared.sessionize_spawn(session.session.instance_id);
16549            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16550            Ok(NodeResponse::Accepted)
16551        }
16552        NodeRequest::Remove { session } => {
16553            controlled_session(shared, connection_id, role, &session)?;
16554            shared.remove_session(&session).await?;
16555            Ok(NodeResponse::Accepted)
16556        }
16557        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16558            shared.require_controller(connection_id, role)?;
16559            let snapshot = shared.require_acp_session(&session)?;
16560            let interaction = snapshot
16561                .provider
16562                .interactions
16563                .iter()
16564                .find(|interaction| {
16565                    interaction_correlation(
16566                        session.session.instance_id,
16567                        session.session.generation,
16568                        interaction.id.0,
16569                    ) == correlation_id
16570                })
16571                .ok_or_else(|| failure(
16572                    NodeFailureCode::InvalidRequest,
16573                    "unknown interaction correlation",
16574                ))?;
16575            if interaction.status != ProviderInteractionStatus::Pending {
16576                return Err(failure(
16577                    NodeFailureCode::BackendOperationFailed,
16578                    "interaction has already been resolved",
16579                ));
16580            }
16581            response
16582                .validate_for(interaction.interaction_kind)
16583                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16584            let interaction_id = interaction.id;
16585            let response_kind = response.kind();
16586            let response_outcome = response.outcome();
16587            shared.dispatch(ControlCommand::ResolveInteraction {
16588                instance_id: session.session.instance_id,
16589                generation: session.session.generation,
16590                interaction_id,
16591                response,
16592            })?;
16593            // The one line that lets an operator compare what THIS node
16594            // recorded against what the provider itself reports doing --
16595            // measured live on a kimi run where our side resolved `true`
16596            // while the agent reported "rejected" and neither layer had a
16597            // line to line up against the other.
16598            tracing::info!(
16599                node_id = %shared.node_id,
16600                session = ?session.session,
16601                correlation_id = %correlation_id,
16602                interaction_id = interaction_id.0,
16603                response_kind = ?response_kind,
16604                outcome = ?response_outcome,
16605                "interaction resolved",
16606            );
16607            Ok(NodeResponse::Accepted)
16608        }
16609        NodeRequest::SetSessionMode { session, mode_id } => {
16610            shared.require_controller(connection_id, role)?;
16611            shared.require_acp_session(&session)?;
16612            shared.dispatch(ControlCommand::SetSessionMode {
16613                instance_id: session.session.instance_id,
16614                mode_id,
16615            })?;
16616            Ok(NodeResponse::Accepted)
16617        }
16618        NodeRequest::SetSessionConfigOption {
16619            session,
16620            option_id,
16621            value_json,
16622        } => {
16623            shared.require_controller(connection_id, role)?;
16624            shared.require_acp_session(&session)?;
16625            shared.dispatch(ControlCommand::SetSessionConfigOption {
16626                instance_id: session.session.instance_id,
16627                option_id,
16628                value_json,
16629            })?;
16630            Ok(NodeResponse::Accepted)
16631        }
16632        NodeRequest::SetSessionModel { session, model_id } => {
16633            shared.require_controller(connection_id, role)?;
16634            shared.require_acp_session(&session)?;
16635            shared.dispatch(ControlCommand::SetSessionModel {
16636                instance_id: session.session.instance_id,
16637                model_id,
16638            })?;
16639            Ok(NodeResponse::Accepted)
16640        }
16641        NodeRequest::Shutdown => {
16642            shared.require_controller(connection_id, role)?;
16643            shared.begin_shutdown_locked().await?;
16644            Ok(NodeResponse::ShuttingDown)
16645        }
16646    }
16647}
16648
16649fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16650    shared.require_controller(connection_id, role)?;
16651    shared.validate_address(session)
16652}
16653
16654/// Selects the sole history candidate matching `provider_session_id` when
16655/// the managed record carries a verified identity, or the sole candidate in
16656/// the discovered set at all when it does not (see
16657/// `export_context_pack_for_session_record_inner`'s identity match). Either
16658/// way, more than one match refuses instead of guessing which one is exact.
16659fn select_history_candidate(
16660    candidates: &[HistoryCandidateSummary],
16661    provider_session_id: Option<&str>,
16662) -> Result<String, NodeFailure> {
16663    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16664        Some(id) => candidate.session_id_hint == id,
16665        None => true,
16666    });
16667    let candidate = matches.next().ok_or_else(|| {
16668        failure(
16669            NodeFailureCode::SessionRecordConflict,
16670            if provider_session_id.is_some() {
16671                "no history candidate matches the managed provider session"
16672            } else {
16673                "no history candidate exists for the managed session"
16674            },
16675        )
16676    })?;
16677    if matches.next().is_some() {
16678        return Err(failure(
16679            NodeFailureCode::SessionRecordConflict,
16680            if provider_session_id.is_some() {
16681                "history identity is ambiguous for the managed provider session"
16682            } else {
16683                "history is ambiguous without a verified provider session identity"
16684            },
16685        ));
16686    }
16687    Ok(candidate.id.clone())
16688}
16689
16690fn resolved_spawn_profile_summary(
16691    profile: &SpawnProfileDefaults,
16692    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16693) -> Option<SpawnProfileSummary> {
16694    let environment_profile = match profile.environment_profile_id.as_ref() {
16695        None => None,
16696        Some(profile_id) => {
16697            let binding = environment_profiles.get(profile_id)?;
16698            if binding.provider != profile.provider
16699                || binding.native_profile_id(profile.mode).is_none()
16700            {
16701                return None;
16702            }
16703            Some(ResolvedEnvironmentProfileReceipt {
16704                profile_id: binding.id.clone(),
16705                profile_revision: binding.revision.clone(),
16706                network_allowlist: None,
16707                browser_profile_id: None,
16708            })
16709        }
16710    };
16711    Some(SpawnProfileSummary {
16712        id: profile.profile_id.clone(),
16713        revision: profile.revision.clone(),
16714        environment_profile,
16715    })
16716}
16717
16718/// True if `current` is an acceptable version of `expected` at export
16719/// revalidation/commit time.
16720///
16721/// When `allow_clean_detachment` is `false` (the on-demand,
16722/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16723/// be byte-identical to `expected` and still actively bound to `session` —
16724/// the original, unchanged invariant.
16725///
16726/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16727/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16728/// export reacts to produces on its own: `state` Live -> Dormant,
16729/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16730/// advancing. Every other field — including `provider_session`, `context`,
16731/// `bundle`, `task_binding` — must still match exactly. A record that changed
16732/// in any other way (rebind, a different active session, an identity
16733/// conflict, capacity eviction) is rejected either way; this widens nothing
16734/// about which sessions are eligible, only how the record's own expected
16735/// detachment is tolerated mid-export.
16736fn session_record_export_target_matches(
16737    expected: &ManagedSessionRecord,
16738    current: &ManagedSessionRecord,
16739    session: &SessionAddress,
16740    allow_clean_detachment: bool,
16741) -> bool {
16742    if current == expected && current.active_session.as_ref() == Some(session) {
16743        return current.state == ManagedSessionState::Live;
16744    }
16745    allow_clean_detachment
16746        && current.state == ManagedSessionState::Dormant
16747        && current.active_session.is_none()
16748        && current.record_id == expected.record_id
16749        && current.display_name == expected.display_name
16750        && current.provider == expected.provider
16751        && current.mode == expected.mode
16752        && current.workspace_id == expected.workspace_id
16753        && current.canonical_root == expected.canonical_root
16754        && current.provider_session == expected.provider_session
16755        && current.environment_profile == expected.environment_profile
16756        && current.bundle == expected.bundle
16757        && current.context_id == expected.context_id
16758        && current.context == expected.context
16759        && current.exported_context == expected.exported_context
16760        && current.task_binding == expected.task_binding
16761        && current.created_at_unix_ms == expected.created_at_unix_ms
16762        && current.last_error == expected.last_error
16763}
16764
16765fn session_record_context_export_binding_is_exact(
16766    expected: &ManagedSessionRecord,
16767    current: &ManagedSessionRecord,
16768    identity: Option<&ProviderSessionIdentity>,
16769    session: &SessionAddress,
16770    binding: &SessionBinding,
16771    provider: &AgentId,
16772    current_root: &str,
16773    allow_clean_detachment: bool,
16774) -> bool {
16775    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16776        && current.provider_session.as_ref() == identity
16777        && current.workspace_id == session.workspace_id
16778        && provider == &current.provider
16779        && binding.workspace_id == session.workspace_id
16780        && binding.generation == session.session.generation
16781        && binding.record_id.as_ref() == Some(&current.record_id)
16782        && platform::roots_equal(
16783            current_root,
16784            windows_path_text(&current.canonical_root),
16785        )
16786}
16787
16788fn session_record_context_export_source_is_usable(
16789    record: &ManagedSessionRecord,
16790    status: &SessionStatus,
16791    allow_clean_detachment: bool,
16792) -> bool {
16793    (record.state == ManagedSessionState::Live
16794        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16795        && context_pack_source_status_is_usable(status)
16796}
16797
16798fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16799    PromptFraming::BracketedPaste
16800}
16801
16802fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16803    if text.len() > MAX_NODE_TEXT_BYTES {
16804        return Err(failure(
16805            NodeFailureCode::InvalidRequest,
16806            &format!(
16807                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16808                text.len(),
16809            ),
16810        ));
16811    }
16812    Ok(())
16813}
16814
16815fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16816    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16817        return Err(failure(
16818            NodeFailureCode::InvalidRequest,
16819            &format!(
16820                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16821                bytes.len(),
16822            ),
16823        ));
16824    }
16825    Ok(())
16826}
16827
16828fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16829    validate_terminal_bytes(&bytes)?;
16830    Ok(InputAction::TerminalBytes(bytes))
16831}
16832
16833fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16834    if root.is_empty()
16835        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16836        || root.chars().any(char::is_control)
16837    {
16838        return Err(failure(
16839            NodeFailureCode::InvalidWorkspaceRoot,
16840            &format!(
16841                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16842            ),
16843        ));
16844    }
16845    Ok(())
16846}
16847
16848fn canonical_standalone_workspace_candidate(
16849    workspace_id: &WorkspaceId,
16850    root: &str,
16851) -> Result<String, NodeServerError> {
16852    let path = Path::new(root);
16853    match std::fs::symlink_metadata(path) {
16854        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16855            .map(|workspace| workspace.canonical_root),
16856        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16857            if !path.is_absolute() {
16858                return Err(NodeServerError::InvalidWorkspaceRoot {
16859                    workspace_id: workspace_id.clone(),
16860                    path: root.to_owned(),
16861                    message: "path is not absolute".to_owned(),
16862                });
16863            }
16864            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16865                workspace_id: workspace_id.clone(),
16866                path: root.to_owned(),
16867                message: "path has no standalone directory name".to_owned(),
16868            })?;
16869            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16870                workspace_id: workspace_id.clone(),
16871                path: root.to_owned(),
16872                message: "path has no parent directory".to_owned(),
16873            })?;
16874            let canonical = std::fs::canonicalize(parent)
16875                .map(|parent| parent.join(name))
16876                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16877                    workspace_id: workspace_id.clone(),
16878                    path: root.to_owned(),
16879                    message: error.to_string(),
16880                })?;
16881            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16882                NodeServerError::InvalidWorkspaceRoot {
16883                    workspace_id: workspace_id.clone(),
16884                    path: path.to_string_lossy().into_owned(),
16885                    message: "canonical path is not valid Unicode".to_owned(),
16886                }
16887            })?;
16888            let canonical_root = platform::normalize_canonical_root(canonical_root);
16889            validate_workspace_root(workspace_id, &canonical_root)?;
16890            Ok(canonical_root)
16891        }
16892        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16893            workspace_id: workspace_id.clone(),
16894            path: root.to_owned(),
16895            message: error.to_string(),
16896        }),
16897    }
16898}
16899
16900fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16901    let Some(revision) = revision else {
16902        return Ok(());
16903    };
16904    if revision.is_empty()
16905        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16906        || revision.chars().any(char::is_control)
16907    {
16908        return Err(failure(
16909            NodeFailureCode::InvalidRequest,
16910            &format!(
16911                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16912            ),
16913        ));
16914    }
16915    Ok(())
16916}
16917
16918fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16919    let code = match error.kind {
16920        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16921        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16922        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16923        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16924        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16925        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16926        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16927    };
16928    failure(code, &error.message)
16929}
16930
16931fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16932    let code = match error.kind {
16933        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16934        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16935        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16936            NodeFailureCode::BackendOperationFailed
16937        }
16938        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16939            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16940        }
16941    };
16942    failure(code, error.message)
16943}
16944
16945fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16946    let (code, message) = match error.kind {
16947        GitWorktreeErrorKind::Invalid => (
16948            NodeFailureCode::InvalidRequest,
16949            "managed Git input is invalid",
16950        ),
16951        GitWorktreeErrorKind::NotRepository => (
16952            NodeFailureCode::NotGitRepository,
16953            "managed source is not a Git repository",
16954        ),
16955        GitWorktreeErrorKind::Conflict => (
16956            NodeFailureCode::WorktreeConflict,
16957            "managed Git identity conflicts with existing repository state",
16958        ),
16959        GitWorktreeErrorKind::Protected => (
16960            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16961            "managed worktree ownership validation failed",
16962        ),
16963        GitWorktreeErrorKind::Dirty => (
16964            NodeFailureCode::WorktreeDirty,
16965            "managed worktree contains uncommitted changes",
16966        ),
16967        GitWorktreeErrorKind::Locked => (
16968            NodeFailureCode::WorktreeLocked,
16969            "managed worktree is locked",
16970        ),
16971        GitWorktreeErrorKind::Failed => (
16972            NodeFailureCode::BackendOperationFailed,
16973            "managed Git operation failed",
16974        ),
16975    };
16976    NodeFailure { code, message: message.to_owned() }
16977}
16978
16979fn managed_spawn_receipt(
16980    resolved: &ResolvedSpawnSpec,
16981    incarnation_id: NodeIncarnationId,
16982    session: SessionAddress,
16983    lease: ManagedWorktreeLeaseSnapshot,
16984    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16985    bundle: Option<ResolvedBundleReceipt>,
16986    context: Option<ResolvedContextPackReceipt>,
16987) -> ManagedWorktreeSpawnReceipt {
16988    let mut managed = resolved.clone();
16989    managed.target.worktree_id = Some(lease.workspace_id.clone());
16990    ManagedWorktreeSpawnReceipt {
16991        spawn: managed.receipt_with_materialization(
16992            incarnation_id,
16993            session,
16994            environment_profile,
16995            bundle,
16996            context,
16997        ),
16998        lease,
16999    }
17000}
17001
17002fn validate_selected_worktree(
17003    source_root: &str,
17004    selected_root: &str,
17005    worktrees: &[NativeGitWorktreeSnapshot],
17006) -> Result<(), NodeFailure> {
17007    if !worktrees
17008        .iter()
17009        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17010    {
17011        return Err(failure(
17012            NodeFailureCode::WorktreeProtected,
17013            "source workspace is not an authoritative non-bare Git worktree root",
17014        ));
17015    }
17016    if !worktrees.iter().any(|worktree| {
17017        worktree_paths_equal(&worktree.path, selected_root)
17018            && !worktree.is_main
17019            && !worktree.is_bare
17020    }) {
17021        return Err(failure(
17022            NodeFailureCode::WorktreeProtected,
17023            "selected workspace is not an eligible Git worktree in the source repository",
17024        ));
17025    }
17026    Ok(())
17027}
17028
17029fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17030    let code = match error.kind() {
17031        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17032        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17033            NodeFailureCode::RepositoryPathUnsafe
17034        }
17035        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17036        WorkspaceFileReadErrorKind::NotRegularFile => {
17037            NodeFailureCode::RepositoryFileNotRegular
17038        }
17039        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17040            NodeFailureCode::RepositoryFileReadFailed
17041        }
17042        WorkspaceFileReadErrorKind::RevisionConflict => {
17043            NodeFailureCode::RepositoryFileRevisionConflict
17044        }
17045        WorkspaceFileReadErrorKind::AlreadyExists
17046        | WorkspaceFileReadErrorKind::ParentNotFound
17047        | WorkspaceFileReadErrorKind::ParentNotDirectory
17048        | WorkspaceFileReadErrorKind::Canceled => {
17049            NodeFailureCode::RepositoryFileReadFailed
17050        }
17051    };
17052    failure(code, "repository file read failed")
17053}
17054
17055fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17056    let code = match error.kind() {
17057        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17058        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17059            NodeFailureCode::RepositoryPathUnsafe
17060        }
17061        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17062        WorkspaceFileReadErrorKind::NotRegularFile => {
17063            NodeFailureCode::RepositoryFileNotRegular
17064        }
17065        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17066            NodeFailureCode::RepositoryFileWriteFailed
17067        }
17068        WorkspaceFileReadErrorKind::RevisionConflict => {
17069            NodeFailureCode::RepositoryFileRevisionConflict
17070        }
17071        WorkspaceFileReadErrorKind::AlreadyExists
17072        | WorkspaceFileReadErrorKind::ParentNotFound
17073        | WorkspaceFileReadErrorKind::ParentNotDirectory
17074        | WorkspaceFileReadErrorKind::Canceled => {
17075            NodeFailureCode::RepositoryFileWriteFailed
17076        }
17077    };
17078    failure(code, "repository file write failed")
17079}
17080
17081async fn settle_workspace_entry_create<T>(
17082    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17083    commit_state: Arc<AtomicU8>,
17084    deadline: Duration,
17085    timeout_message: &'static str,
17086    task_failure_message: &'static str,
17087) -> Result<T, NodeFailure> {
17088    let joined = match timeout(deadline, &mut task).await {
17089        Ok(joined) => joined,
17090        Err(_) => {
17091            if commit_state
17092                .compare_exchange(
17093                    WORKSPACE_ENTRY_CREATE_PENDING,
17094                    WORKSPACE_ENTRY_CREATE_CANCELED,
17095                    Ordering::AcqRel,
17096                    Ordering::Acquire,
17097                )
17098                .is_ok()
17099            {
17100                return Err(failure(
17101                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17102                    timeout_message,
17103                ));
17104            }
17105            debug_assert_eq!(
17106                commit_state.load(Ordering::Acquire),
17107                WORKSPACE_ENTRY_CREATE_COMMITTING,
17108            );
17109            task.await
17110        }
17111    };
17112    joined
17113        .map_err(|_| {
17114            failure(
17115                NodeFailureCode::RepositoryEntryCreateFailed,
17116                task_failure_message,
17117            )
17118        })?
17119        .map_err(workspace_entry_create_failure)
17120}
17121
17122fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17123    let code = match error.kind() {
17124        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17125        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17126            NodeFailureCode::RepositoryPathUnsafe
17127        }
17128        WorkspaceFileReadErrorKind::AlreadyExists => {
17129            NodeFailureCode::RepositoryEntryAlreadyExists
17130        }
17131        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17132            NodeFailureCode::RepositoryParentNotFound
17133        }
17134        WorkspaceFileReadErrorKind::ParentNotDirectory
17135        | WorkspaceFileReadErrorKind::NotRegularFile => {
17136            NodeFailureCode::RepositoryParentNotDirectory
17137        }
17138        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17139            NodeFailureCode::RepositoryEntryCreateFailed
17140        }
17141        WorkspaceFileReadErrorKind::RevisionConflict => {
17142            NodeFailureCode::RepositoryEntryCreateFailed
17143        }
17144        WorkspaceFileReadErrorKind::Canceled => {
17145            NodeFailureCode::RepositoryEntryCreateTimedOut
17146        }
17147    };
17148    failure(code, "repository entry create failed")
17149}
17150
17151fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17152    let value = digest(&SHA256, bytes)
17153        .as_ref()
17154        .iter()
17155        .map(|byte| format!("{byte:02x}"))
17156        .collect();
17157    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17158}
17159
17160fn managed_spawn_request_digest_v2(
17161    request: &ManagedWorktreeSpawnRequestV2,
17162) -> Result<String, NodeFailure> {
17163    let encoded = serde_json::to_vec(request).map_err(|_| {
17164        failure(
17165            NodeFailureCode::InvalidRequest,
17166            "managed worktree V2 request could not be canonicalized",
17167        )
17168    })?;
17169    let mut value = String::with_capacity(71);
17170    value.push_str("sha256:");
17171    for byte in digest(&SHA256, &encoded).as_ref() {
17172        use std::fmt::Write as _;
17173        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17174    }
17175    Ok(value)
17176}
17177
17178fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17179    let code = match error {
17180        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17181        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17182        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17183        NativeSessionPreviewError::UnsupportedProvider
17184        | NativeSessionPreviewError::PreviewUnavailable
17185        | NativeSessionPreviewError::SessionNotFound
17186        | NativeSessionPreviewError::AmbiguousSession => {
17187            NodeFailureCode::BackendOperationFailed
17188        }
17189    };
17190    failure(code, "native session preview failed")
17191}
17192
17193fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17194    let code = match error.kind() {
17195        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17196        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17197    };
17198    failure(code, "host directory browse failed")
17199}
17200
17201fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17202    let code = match error {
17203        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17204        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17205        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17206            NodeFailureCode::DeliveryStageConflict
17207        }
17208        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17209        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17210            NodeFailureCode::DeliveryChunkOutOfOrder
17211        }
17212        DeliveryStoreError::BlobDigestMismatch => {
17213            NodeFailureCode::DeliveryBlobDigestMismatch
17214        }
17215        DeliveryStoreError::BundleDigestMismatch => {
17216            NodeFailureCode::DeliveryBundleDigestMismatch
17217        }
17218        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17219        DeliveryStoreError::Unavailable
17220        | DeliveryStoreError::Corrupt
17221        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17222    };
17223    failure(code, "delivery operation failed")
17224}
17225
17226fn validate_workspace_root(
17227    workspace_id: &WorkspaceId,
17228    canonical_root: &str,
17229) -> Result<(), NodeServerError> {
17230    #[cfg(windows)]
17231    if canonical_root.starts_with(r"\\") {
17232        return Err(NodeServerError::InvalidWorkspaceRoot {
17233            workspace_id: workspace_id.clone(),
17234            path: canonical_root.to_owned(),
17235            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17236        });
17237    }
17238    if !platform::workspace_root_supported(canonical_root)
17239        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17240    {
17241        return Err(NodeServerError::InvalidWorkspaceRoot {
17242            workspace_id: workspace_id.clone(),
17243            path: canonical_root.to_owned(),
17244            message: "canonical path must be absolute, bounded, and free of control characters"
17245                .to_owned(),
17246        });
17247    }
17248    Ok(())
17249}
17250
17251fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17252    NodeFailure {
17253        code,
17254        message: node_failure_category(code).to_owned(),
17255    }
17256}
17257
17258fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17259    let code = match error {
17260        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17261        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17262        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17263        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17264        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17265        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17266        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17267        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17268        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17269    };
17270    failure(code, "harness MCP proxy operation failed")
17271}
17272
17273/// Numbers each harness-MCP overlay this process constructs, in
17274/// construction order, so two overlays built back to back in one node process
17275/// still get distinct trace files.
17276static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17277
17278/// Builds the optional trace environment entry of one harness-MCP overlay.
17279/// The caller's launch description names both the entry and the node-process
17280/// variable that opts in: when that variable holds a non-empty directory, the
17281/// entry names a trace file under it. Returns `None` when the launch has no
17282/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17283/// the directory; that is the operator's job.
17284fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17285    let trace = launch.trace.as_ref()?;
17286    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17287    if trace_dir.is_empty() {
17288        return None;
17289    }
17290    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17291    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17292    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17293}
17294
17295fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17296    let remaining = deadline.saturating_duration_since(Instant::now());
17297    if remaining.is_zero() {
17298        Err(failure(
17299            NodeFailureCode::SpawnDeadlineExceeded,
17300            "spawn deadline elapsed",
17301        ))
17302    } else {
17303        Ok(remaining)
17304    }
17305}
17306
17307fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17308    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17309    match deadline {
17310        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17311        None => Ok(bounded),
17312    }
17313}
17314
17315fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17316    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17317        failure(
17318            NodeFailureCode::SpawnDeadlineExceeded,
17319            "spawn dispatch exceeded the spawn deadline",
17320        )
17321    } else {
17322        error
17323    }
17324}
17325
17326fn spawn_runtime_capabilities(
17327    capabilities: &SpawnRequiredCapabilities,
17328) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17329    capabilities
17330        .iter()
17331        .map(|capability| match capability.as_str() {
17332            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17333            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17334            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17335            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17336                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17337            }
17338            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17339            _ => Err(failure(
17340                NodeFailureCode::UnsupportedSpawnCapability,
17341                "spawn required capability is unknown",
17342            )),
17343        })
17344        .collect()
17345}
17346
17347fn persistence_failure(_error: io::Error) -> NodeFailure {
17348    NodeFailure {
17349        code: NodeFailureCode::BackendOperationFailed,
17350        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17351    }
17352}
17353
17354fn node_failure_category(code: NodeFailureCode) -> &'static str {
17355    match code {
17356        NodeFailureCode::InvalidRequest => "invalid-request",
17357        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17358        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17359        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17360        NodeFailureCode::ReservationConflict => "reservation-conflict",
17361        NodeFailureCode::ReservationExpired => "reservation-expired",
17362        NodeFailureCode::BindingMismatch => "binding-mismatch",
17363        NodeFailureCode::NotActivated => "not-activated",
17364        NodeFailureCode::CallNotFound => "call-not-found",
17365        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17366        NodeFailureCode::ResponseTooLarge => "response-too-large",
17367        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17368        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17369        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17370        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17371        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17372        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17373        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17374        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17375        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17376        NodeFailureCode::Unauthorized => "unauthorized",
17377        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17378        NodeFailureCode::ControllerBusy => "controller-busy",
17379        NodeFailureCode::ControllerRequired => "controller-required",
17380        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17381        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17382        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17383        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17384        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17385        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17386        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17387        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17388        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17389        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17390        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17391        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17392        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17393        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17394        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17395        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17396        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17397        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17398        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17399        NodeFailureCode::GitReadFailed => "git-read-failed",
17400        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17401        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17402        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17403        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17404        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17405        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17406        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17407        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17408        NodeFailureCode::TurnInFlight => "turn-in-flight",
17409        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17410        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17411        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17412            "unsupported-network-allowlist-mapping"
17413        }
17414        NodeFailureCode::BrowserStationProbeUnavailable => {
17415            "browser-station-probe-unavailable"
17416        }
17417        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17418        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17419        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17420            "environment-profile-binding-mismatch"
17421        }
17422        NodeFailureCode::UnknownBundle => "unknown-bundle",
17423        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17424        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17425        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17426        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17427        NodeFailureCode::ContextPackMaterializationFailed => {
17428            "context-pack-materialization-failed"
17429        }
17430        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17431        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17432        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17433        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17434        NodeFailureCode::LastWorkspace => "last-workspace",
17435        NodeFailureCode::NotGitRepository => "not-git-repository",
17436        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17437        NodeFailureCode::WorktreeProtected => "worktree-protected",
17438        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17439        NodeFailureCode::WorktreeLocked => "worktree-locked",
17440        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17441        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17442        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17443        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17444            "managed-worktree-profile-revision-mismatch"
17445        }
17446        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17447        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17448            "standalone-workspace-recovery-required"
17449        }
17450        NodeFailureCode::UnknownSession => "unknown-session",
17451        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17452        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17453        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17454        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17455        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17456        NodeFailureCode::WorkspaceRegistrationRequired => {
17457            "workspace-registration-required"
17458        }
17459        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17460        NodeFailureCode::StaleGeneration => "stale-generation",
17461        NodeFailureCode::BackendBusy => "backend-busy",
17462        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17463        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17464        NodeFailureCode::ShuttingDown => "shutting-down",
17465    }
17466}
17467
17468fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17469    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17470}
17471
17472fn durable_state_load_error(error: io::Error) -> NodeServerError {
17473    let category = match session_registry::state_load_refusal(&error) {
17474        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17475            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17476        }
17477        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17478            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17479        }
17480        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17481            DURABLE_STATE_CONFLICT_ERROR
17482        }
17483        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17484    };
17485    durable_state_server_error(error, category)
17486}
17487
17488fn unix_time_ms() -> u64 {
17489    std::time::SystemTime::now()
17490        .duration_since(std::time::UNIX_EPOCH)
17491        .unwrap_or_default()
17492        .as_millis()
17493        .min(u64::MAX as u128) as u64
17494}
17495
17496fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17497    if record.provider_session.is_some() {
17498        ManagedSessionState::Dormant
17499    } else {
17500        ManagedSessionState::Unavailable
17501    }
17502}
17503
17504fn active_record_state(
17505    identity_admitted: bool,
17506    provider_session_is_known: bool,
17507) -> ManagedSessionState {
17508    if identity_admitted && !provider_session_is_known {
17509        ManagedSessionState::IdentityPending
17510    } else {
17511        ManagedSessionState::Live
17512    }
17513}
17514
17515fn managed_worktree_record_holders(
17516    records: &[ManagedSessionRecord],
17517) -> Vec<(SessionRecordId, WorkspaceId)> {
17518    records
17519        .iter()
17520        .filter(|record| record.provider_session.is_some())
17521        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17522        .collect()
17523}
17524
17525fn managed_spawn_record_ownership(
17526    binding: &SessionBinding,
17527    effective_runtime_policy: ProviderRuntimePolicy,
17528) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17529    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17530    if effective_runtime_policy.provider_session_identity {
17531        (binding.record_id.clone(), None)
17532    } else {
17533        (None, binding.record_id.clone())
17534    }
17535}
17536
17537#[derive(Debug, Error)]
17538pub enum NodeServerError {
17539    #[error("node endpoint must be a bounded absolute local transport path")]
17540    InvalidEndpoint,
17541    #[error("node access token must contain 1..=4096 bytes")]
17542    InvalidAccessToken,
17543    #[error("node HTTP observer must listen on a loopback address: {0}")]
17544    InvalidApiListen(std::net::SocketAddr),
17545    #[error("node browser bridge must listen on a loopback address: {0}")]
17546    InvalidBridgeListen(std::net::SocketAddr),
17547    #[error("node browser bridge token must contain 1..=4096 bytes")]
17548    InvalidBridgeToken,
17549    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17550    BridgeUnderlayRequiresBridgeListen,
17551    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17552    BridgeUnderlayRequiresBridgeToken,
17553    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17554    InvalidBridgeUnderlayToken,
17555    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17556    InvalidBridgeUnderlayKey,
17557    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17558    InvalidCallHome(std::net::SocketAddr),
17559    #[error("node could not reach its call-home relay: {0}")]
17560    CallHomeTransport(#[source] std::io::Error),
17561    #[error("node durable state path must be an absolute file path: {0}")]
17562    InvalidStatePath(String),
17563    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17564    InvalidHarnessMcpHelper,
17565    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17566    InvalidNetworkAllowlistCatalogPath(String),
17567    #[error("network allowlist catalog could not be read: {0}")]
17568    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17569    #[error("network allowlist catalog line {line}: {message}")]
17570    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17571    #[error("network allowlist catalog: {0}")]
17572    InvalidNetworkAllowlistCatalog(String),
17573    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17574    NetworkAllowlistCatalogCapacity { max: usize },
17575    #[error("the local state directory is unavailable; supply an explicit state path")]
17576    LocalStateDirectoryUnavailable,
17577    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17578    LocalRuntimeDirectoryUnavailable,
17579    #[error("node durable state failed: {0}")]
17580    DurableState(io::Error),
17581    #[error("node requires at least one configured workspace")]
17582    NoWorkspaces,
17583    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17584    InvalidWorkspaceRoot {
17585        workspace_id: WorkspaceId,
17586        path: String,
17587        message: String,
17588    },
17589    #[error("duplicate workspace ID: {0}")]
17590    DuplicateWorkspaceId(WorkspaceId),
17591    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17592    DuplicateWorkspaceRoot {
17593        first: WorkspaceId,
17594        second: WorkspaceId,
17595        root: String,
17596    },
17597    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17598    ManagedWorktreeProfileRequired(WorkspaceId),
17599    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17600    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17601    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17602    DuplicateManagedWorktreeProfile {
17603        workspace_id: WorkspaceId,
17604        profile_id: WorktreeProfileId,
17605    },
17606    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17607    ManagedWorktreeProfileCapacity {
17608        workspace_id: WorkspaceId,
17609        max: usize,
17610    },
17611    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17612    InvalidManagedWorktreeProfile {
17613        workspace_id: WorkspaceId,
17614        profile_id: WorktreeProfileId,
17615        message: String,
17616    },
17617    #[error("duplicate node environment profile: {0}")]
17618    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17619    #[error("node environment profile provider is unavailable: {0}")]
17620    EnvironmentProfileProviderUnavailable(AgentId),
17621    #[error("node environment profile capacity is {max}")]
17622    EnvironmentProfileCapacity { max: usize },
17623    #[error("node environment profiles reuse native profile ID '{0}'")]
17624    DuplicateNativeEnvironmentProfile(String),
17625    #[error("node native environment profile is invalid: {0}")]
17626    NativeEnvironmentProfile(String),
17627    #[error("node session-environment materialization root must be absolute")]
17628    InvalidSessionEnvironmentRoot,
17629    #[error("node session-environment materializer is unavailable")]
17630    SessionEnvironmentMaterializerRequired,
17631    #[error("node session-environment materializer failed to initialize")]
17632    SessionEnvironmentMaterializer,
17633    #[error("node bundle catalog is invalid: {0}")]
17634    BundleCatalog(String),
17635    #[error("node delivery store failed to initialize: {0}")]
17636    DeliveryStore(String),
17637    #[error("node context pack store failed to initialize")]
17638    ContextPackStore,
17639    #[error("active agent registry failed: {0}")]
17640    Registry(String),
17641    #[error("node provider contract manifest is invalid: {0}")]
17642    ProviderContractManifest(String),
17643    #[error("named pipe I/O failed: {0}")]
17644    Io(#[from] io::Error),
17645    #[error("node HTTP observer failed: {0}")]
17646    HttpApi(io::Error),
17647    #[error("node browser bridge failed: {0}")]
17648    BridgeApi(io::Error),
17649    #[error(transparent)]
17650    Frame(#[from] FrameError),
17651    #[error("node handshake failed: {0}")]
17652    Handshake(String),
17653    #[error("node authentication frame was not received before the bounded deadline")]
17654    AuthenticationTimedOut,
17655    #[error("node authentication primitive failed: {0}")]
17656    Authentication(String),
17657    #[error("node incarnation identity generation failed: {0}")]
17658    IncarnationIdentity(String),
17659    #[error("node pre-authentication limiter is closed")]
17660    PreauthClosed,
17661    #[error("node authenticated connection limit was reached")]
17662    AuthenticatedConnectionLimit,
17663    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17664    SignalStreamClosed,
17665    #[error("node stop-all dispatch failed: {0:?}")]
17666    ShutdownDispatch(NodeFailure),
17667    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17668    ShutdownTimedOut { active_native_sessions: usize },
17669}
17670
17671impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17672    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17673        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17674        match error {
17675            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17676            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17677            E::InvalidEntry { line, message } => {
17678                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17679            }
17680            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17681            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17682        }
17683    }
17684}
17685
17686#[cfg(test)]
17687mod agent_stream_blocked_tests {
17688    use super::*;
17689    use gate4agent_types::{
17690        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17691        ProviderSource,
17692    };
17693
17694    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17695        ProviderSource {
17696            family,
17697            binding: AdapterBinding::new(
17698                AdapterId::new(adapter).unwrap(),
17699                "fixture/v1",
17700                AdapterVerification::SyntheticFixture,
17701            )
17702            .unwrap(),
17703        }
17704    }
17705
17706    fn provider_control_event_at(
17707        family: AdapterFamily,
17708        adapter: &str,
17709        source_sequence: u64,
17710        provider_event: ProviderEvent,
17711    ) -> ControlEvent {
17712        ControlEvent {
17713            sequence: 41,
17714            command_id: None,
17715            instance_id: AgentInstanceId(7),
17716            generation: SessionGeneration(3),
17717            event: ControlEventKind::ProviderEvent {
17718                sequence: 8,
17719                source: provider_source(family, adapter),
17720                source_sequence,
17721                event: provider_event,
17722            },
17723        }
17724    }
17725
17726    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17727        provider_control_event_at(family, "fixture", 9, provider_event)
17728    }
17729
17730    fn denied_host_request(
17731        by: ProviderHostDecisionAuthority,
17732        reason: Option<String>,
17733    ) -> ControlEvent {
17734        provider_control_event_at(
17735            AdapterFamily::Acp,
17736            "claude-code",
17737            2,
17738            ProviderEvent::HostRequestObserved {
17739                method: "terminal/create".to_owned(),
17740                params_json: String::new(),
17741                decision: ProviderHostRequestDecision::Denied { by },
17742                outcome: ProviderHostRequestOutcome::Executed,
17743                reason,
17744            },
17745        )
17746    }
17747
17748    fn blocked(
17749        event: &ControlEvent,
17750    ) -> (
17751        Option<String>,
17752        String,
17753        BlockAuthorityV1,
17754        Option<String>,
17755        String,
17756        Option<String>,
17757    ) {
17758        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17759        chunk.validate().expect("minted chunk must validate");
17760        let AgentStreamChunkKindV1::Blocked {
17761            correlation_id,
17762            tool_class,
17763            authority,
17764            reason_kind,
17765            reason,
17766            help,
17767        } = chunk.kind
17768        else {
17769            panic!("expected AgentStreamChunkKindV1::Blocked");
17770        };
17771        (correlation_id, tool_class, authority, reason_kind, reason, help)
17772    }
17773
17774    /// Pins the fleet and every adapter each member declares: the fleet is
17775    /// exactly what `active_registry` says, and no member declares a hook
17776    /// adapter (lifecycle hooks are retired).
17777    #[test]
17778    fn the_enabled_provider_matrix_is_exact() {
17779        let registry = active_registry().unwrap();
17780        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17781        assert_eq!(
17782            providers
17783                .iter()
17784                .map(|contract| contract.provider.as_str())
17785                .collect::<Vec<_>>(),
17786            // `shell` is the plain PTY provider (operator terminals and smoke tests).
17787            vec!["shell", "claude", "codex", "grok", "kimi"],
17788        );
17789        assert!(adapters.iter().all(|contract| {
17790            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17791        }));
17792    }
17793
17794    /// A gate-denied host request, WITH its reason text carried all the way
17795    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17796    #[test]
17797    fn gate_denied_host_request_mints_a_blocked_chunk() {
17798        let gate_text =
17799            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17800        let event = denied_host_request(
17801            ProviderHostDecisionAuthority::Gate,
17802            Some(gate_text.to_owned()),
17803        );
17804        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17805        assert_eq!(chunk.source_sequence, 2);
17806        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17807        assert_eq!(authority, BlockAuthorityV1::HostGate);
17808        assert_eq!(reason, gate_text);
17809        assert_eq!(correlation_id, None);
17810        assert_eq!(reason_kind, None);
17811        assert_eq!(help, None);
17812        assert_eq!(tool_class, "Write");
17813    }
17814
17815    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.";
17816
17817    /// The owner's classifier sample, verbatim, arriving as a
17818    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17819    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17820    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17821    /// and the guidance tail as `help`.
17822    #[test]
17823    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17824        let event = provider_control_event(
17825            AdapterFamily::Acp,
17826            ProviderEvent::ToolCompleted {
17827                id: "toolu_9".to_owned(),
17828                output: CLASSIFIER_SAMPLE.to_owned(),
17829                is_error: true,
17830                duration_ms: None,
17831                agent_id: None,
17832                non_execution_kind: Some("permission-rule".to_owned()),
17833            },
17834        );
17835        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17836        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17837        assert_eq!(reason, "Blocked by classifier");
17838        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17839        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17840        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17841    }
17842
17843    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17844    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17845    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17846    /// vendor code as `reason_kind`.
17847    #[test]
17848    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17849        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.";
17850        let event = provider_control_event(
17851            AdapterFamily::Acp,
17852            ProviderEvent::SessionEnded {
17853                result: "JSON-RPC error -32603: Internal error".to_owned(),
17854                cost_usd: None,
17855                is_error: true,
17856                stop_reason: Some(ProviderStopReason::ProviderError {
17857                    code: -32603,
17858                    message: message.to_owned(),
17859                    vendor_code: Some("usageLimitExceeded".to_owned()),
17860                }),
17861            },
17862        );
17863        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17864        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17865        assert_eq!(reason, message);
17866        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17867        assert_eq!(tool_class, "Session");
17868    }
17869
17870    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17871    /// `authority: ProviderRefusal`.
17872    #[test]
17873    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17874        let event = provider_control_event(
17875            AdapterFamily::Acp,
17876            ProviderEvent::SessionEnded {
17877                result: "refusal".to_owned(),
17878                cost_usd: None,
17879                is_error: true,
17880                stop_reason: Some(ProviderStopReason::Refusal),
17881            },
17882        );
17883        let (_, _, authority, _, _, _) = blocked(&event);
17884        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17885    }
17886
17887    /// A failed tool completion with no typed `non_execution_kind` -- or one
17888    /// that only says the turn stopped -- is a plain execution failure, never a
17889    /// block; nor is an agent's own narrative claiming one.
17890    #[test]
17891    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17892        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17893            let event = provider_control_event(
17894                AdapterFamily::Acp,
17895                ProviderEvent::ToolCompleted {
17896                    id: "toolu_1".to_owned(),
17897                    output: "command exited with status 1".to_owned(),
17898                    is_error: true,
17899                    duration_ms: Some(12),
17900                    agent_id: None,
17901                    non_execution_kind,
17902                },
17903            );
17904            assert!(agent_stream_chunk(&event).is_none());
17905        }
17906        let narrative = provider_control_event(
17907            AdapterFamily::Acp,
17908            ProviderEvent::ToolCompleted {
17909                id: "toolu_2".to_owned(),
17910                output: "the harness blocked the write, approval needed".to_owned(),
17911                is_error: false,
17912                duration_ms: Some(3),
17913                agent_id: None,
17914                non_execution_kind: None,
17915            },
17916        );
17917        assert!(agent_stream_chunk(&narrative).is_none());
17918    }
17919
17920    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17921    /// execution outcome: a granted request that failed while running was
17922    /// still authorized.
17923    #[test]
17924    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17925        for (decision, outcome) in [
17926            (
17927                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17928                ProviderHostRequestOutcome::Executed,
17929            ),
17930            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17931            (
17932                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17933                ProviderHostRequestOutcome::Failed {
17934                    error: "terminal/create spawn failed: os error 3".to_owned(),
17935                },
17936            ),
17937        ] {
17938            let event = provider_control_event_at(
17939                AdapterFamily::Acp,
17940                "claude-code",
17941                3,
17942                ProviderEvent::HostRequestObserved {
17943                    method: "fs/read_text_file".to_owned(),
17944                    params_json: String::new(),
17945                    decision: decision.clone(),
17946                    outcome,
17947                    reason: None,
17948                },
17949            );
17950            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17951        }
17952    }
17953
17954    /// A deferred `session/request_permission` resolved by an operator or an
17955    /// expired deadline carries no reason text -- the chunk falls back to a
17956    /// categorical sentence naming the authority rather than inventing detail.
17957    #[test]
17958    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17959        for (by, expected_authority, expected_reason) in [
17960            (
17961                ProviderHostDecisionAuthority::Operator,
17962                BlockAuthorityV1::Operator,
17963                "declined by the operator",
17964            ),
17965            (
17966                ProviderHostDecisionAuthority::DeadlinePolicy,
17967                BlockAuthorityV1::HostDeadline,
17968                "denied by host policy after the operator deadline expired",
17969            ),
17970        ] {
17971            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17972            assert_eq!(authority, expected_authority);
17973            assert_eq!(reason, expected_reason);
17974        }
17975    }
17976
17977    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17978    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17979    /// outright rather than truncating.
17980    #[test]
17981    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17982        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17983        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17984            ProviderHostDecisionAuthority::Policy,
17985            Some(oversized),
17986        ));
17987        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17988    }
17989}
17990
17991#[cfg(test)]
17992mod standalone_capability_platform_tests {
17993    use super::*;
17994
17995    #[test]
17996    fn standalone_workspace_capability_requires_windows_identity_guards() {
17997        let capabilities = baseline_capabilities().unwrap();
17998        let advertised = capabilities.iter().any(|capability| {
17999            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18000        });
18001        assert_eq!(advertised, cfg!(windows));
18002
18003        let request = NodeRequest::CreateStandaloneWorkspace {
18004            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18005            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18006            initial_branch: None,
18007        };
18008        assert_eq!(
18009            request_uses_unnegotiated_capability(&request, &capabilities),
18010            !cfg!(windows),
18011        );
18012    }
18013}
18014
18015
18016#[cfg(test)]
18017mod station_knobs_resolve_tests {
18018    use super::*;
18019    use gate4agent_catalog::EnvMutation;
18020    use gate4agent_runtime_native::{
18021        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18022        NativeLaunchProfileId,
18023    };
18024    use std::ffi::OsString;
18025    use std::sync::Arc;
18026
18027    struct EmptyEnvironmentResolver;
18028
18029    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18030        fn resolve_child_environment(
18031            &self,
18032        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18033            Ok(vec![EnvMutation {
18034                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18035                value: None,
18036            }])
18037        }
18038    }
18039
18040    fn agent(value: &str) -> AgentId {
18041        AgentId::new(value).unwrap()
18042    }
18043
18044    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18045        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18046        let profile_revision =
18047            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18048        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18049            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18050            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18051            provider: agent("claude"),
18052            mode: SessionMode::Pty,
18053            terminal_size: gate4agent_types::TerminalSize {
18054                rows: 24,
18055                columns: 80,
18056            },
18057            prompt: None,
18058            bundle_id: None,
18059            context_id: None,
18060            environment_profile_id: Some(profile_id.clone()),
18061        };
18062        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18063        let catalog = active_registry().unwrap();
18064        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18065        let control = runtime.native_launch_profile_control();
18066        let node_profile = NodeEnvironmentProfile::new(
18067            profile_id.clone(),
18068            profile_revision,
18069            agent("claude"),
18070            [NativeLaunchProfile::new(
18071                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18072                agent("claude"),
18073                TransportKind::Pty,
18074                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18075                Arc::new(EmptyEnvironmentResolver),
18076            )
18077            .unwrap()],
18078        )
18079        .unwrap();
18080        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18081        for native_profile in native_profiles {
18082            runtime.upsert_native_launch_profile(native_profile).unwrap();
18083        }
18084        let workspace = WorkspaceConfig::new(
18085            WorkspaceId::new("primary").unwrap(),
18086            std::env::current_dir().unwrap(),
18087        )
18088        .unwrap();
18089        let shared = NodeShared::new_with_incarnation(
18090            handle,
18091            "fixture-token".to_owned(),
18092            NodeId::new("node-station-knobs-test").unwrap(),
18093            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18094            vec![workspace],
18095            vec![agent("claude")],
18096            ProviderRuntimeStatuses::default(),
18097            None,
18098            Vec::new(),
18099            Vec::new(),
18100            spawn_profiles,
18101            Some(control),
18102            None,
18103            None,
18104            None,
18105            Vec::new(),
18106            Vec::new(),
18107            Vec::new(),
18108            Vec::new(),
18109            None,
18110            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18111        );
18112        shared
18113            .environment_profiles
18114            .write()
18115            .unwrap_or_else(|poisoned| poisoned.into_inner())
18116            .insert(binding.id.clone(), binding);
18117        (shared, spawn_defaults)
18118    }
18119
18120    /// Empty-default catalog refuses unknown network_allowlist ids.
18121    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18122    #[test]
18123    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18124        let (shared, defaults) = station_knobs_fixture();
18125        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18126        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18127        let spec = crate::protocol::SpawnSpec {
18128            target: crate::protocol::SpawnTarget {
18129                node_id: shared.node_id.clone(),
18130                workspace_id: WorkspaceId::new("primary").unwrap(),
18131                worktree_id: None,
18132            },
18133            profile_id: defaults.profile_id.clone(),
18134            expected_profile_revision: defaults.revision.clone(),
18135            overrides: crate::protocol::SpawnOverrides {
18136                network_allowlist: Some(unknown),
18137                browser_profile_id: Some(browser),
18138                ..crate::protocol::SpawnOverrides::default()
18139            },
18140            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18141            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18142                "station-knobs-unknown-allowlist",
18143            )
18144            .unwrap(),
18145            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18146        };
18147        let resolved = spec.resolve(&defaults).unwrap();
18148        let failure = shared
18149            .resolve_environment_profile(&resolved)
18150            .expect_err("unknown allowlist must refuse");
18151        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18152    }
18153
18154    /// Registered network_allowlist (+ browser_profile_id when feature off)
18155    /// echo onto the env-profile receipt. Empty catalog still allows omit
18156    /// (None). Empty/whitespace refuse is type-level. Feature-off
18157    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18158    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18159    #[test]
18160    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18161        let (shared, defaults) = station_knobs_fixture();
18162        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18163        #[cfg(not(feature = "dig2-station-probe"))]
18164        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18165        shared
18166            .network_allowlist_catalog
18167            .write()
18168            .unwrap_or_else(|poisoned| poisoned.into_inner())
18169            .insert_id(allowlist.clone())
18170            .unwrap();
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 {
18180                network_allowlist: Some(allowlist.clone()),
18181                #[cfg(not(feature = "dig2-station-probe"))]
18182                browser_profile_id: Some(browser.clone()),
18183                ..crate::protocol::SpawnOverrides::default()
18184            },
18185            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18186            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18187                .unwrap(),
18188            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18189        };
18190        let resolved = spec.resolve(&defaults).unwrap();
18191        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18192        #[cfg(not(feature = "dig2-station-probe"))]
18193        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18194        let receipt = shared
18195            .resolve_environment_profile(&resolved)
18196            .expect("resolve ok")
18197            .expect("env profile present");
18198        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18199        #[cfg(not(feature = "dig2-station-probe"))]
18200        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18201        #[cfg(feature = "dig2-station-probe")]
18202        assert_eq!(receipt.browser_profile_id, None);
18203        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18204
18205        let omitted = crate::protocol::SpawnSpec {
18206            overrides: crate::protocol::SpawnOverrides::default(),
18207            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18208                .unwrap(),
18209            ..spec
18210        };
18211        let resolved_omit = omitted.resolve(&defaults).unwrap();
18212        let bare = shared
18213            .resolve_environment_profile(&resolved_omit)
18214            .expect("resolve ok")
18215            .expect("env profile present");
18216        assert_eq!(bare.network_allowlist, None);
18217        assert_eq!(bare.browser_profile_id, None);
18218    }
18219
18220    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18221    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18222    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18223    /// unit-tested separately (probe refuses before lease on Linux).
18224    #[cfg(feature = "dig2-station-probe")]
18225    #[test]
18226    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18227        let (shared, defaults) = station_knobs_fixture();
18228        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18229        let spec = crate::protocol::SpawnSpec {
18230            target: crate::protocol::SpawnTarget {
18231                node_id: shared.node_id.clone(),
18232                workspace_id: WorkspaceId::new("primary").unwrap(),
18233                worktree_id: None,
18234            },
18235            profile_id: defaults.profile_id.clone(),
18236            expected_profile_revision: defaults.revision.clone(),
18237            overrides: crate::protocol::SpawnOverrides {
18238                browser_profile_id: Some(browser),
18239                ..crate::protocol::SpawnOverrides::default()
18240            },
18241            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18242            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18243                "station-knobs-probe-refuse",
18244            )
18245            .unwrap(),
18246            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18247        };
18248        let resolved = spec.resolve(&defaults).unwrap();
18249        let failure = shared
18250            .resolve_environment_profile(&resolved)
18251            .expect_err("browser_profile with failing probe must refuse");
18252        #[cfg(windows)]
18253        {
18254            assert!(
18255                matches!(
18256                    failure.code,
18257                    NodeFailureCode::BrowserStationUnreachable
18258                        | NodeFailureCode::BrowserStationProbeUnavailable
18259                ),
18260                "unexpected code {:?}",
18261                failure.code
18262            );
18263        }
18264        #[cfg(not(windows))]
18265        {
18266            assert_eq!(
18267                failure.code,
18268                NodeFailureCode::BrowserStationProbeUnavailable
18269            );
18270        }
18271    }
18272
18273    /// Feature on: omitting browser_profile_id still resolves (no probe).
18274    #[cfg(feature = "dig2-station-probe")]
18275    #[test]
18276    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18277        let (shared, defaults) = station_knobs_fixture();
18278        let spec = crate::protocol::SpawnSpec {
18279            target: crate::protocol::SpawnTarget {
18280                node_id: shared.node_id.clone(),
18281                workspace_id: WorkspaceId::new("primary").unwrap(),
18282                worktree_id: None,
18283            },
18284            profile_id: defaults.profile_id.clone(),
18285            expected_profile_revision: defaults.revision.clone(),
18286            overrides: crate::protocol::SpawnOverrides::default(),
18287            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18288            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18289                "station-knobs-probe-omit",
18290            )
18291            .unwrap(),
18292            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18293        };
18294        let resolved = spec.resolve(&defaults).unwrap();
18295        let receipt = shared
18296            .resolve_environment_profile(&resolved)
18297            .expect("omit browser_profile must not probe")
18298            .expect("env profile present");
18299        assert_eq!(receipt.browser_profile_id, None);
18300    }
18301
18302    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18303    /// refuses a second holder for the same opaque profile id; release on
18304    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18305    #[cfg(feature = "dig2-station-probe")]
18306    #[test]
18307    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18308        let (shared, _defaults) = station_knobs_fixture();
18309        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18310        let workspace_id = WorkspaceId::new("primary").unwrap();
18311        let address_a = SessionAddress {
18312            workspace_id: workspace_id.clone(),
18313            session: SessionKey {
18314                instance_id: AgentInstanceId(101),
18315                generation: SessionGeneration(1),
18316            },
18317        };
18318        let address_b = SessionAddress {
18319            workspace_id: workspace_id.clone(),
18320            session: SessionKey {
18321                instance_id: AgentInstanceId(102),
18322                generation: SessionGeneration(1),
18323            },
18324        };
18325        let guard_a = crate::dig2_station_lease::acquire_lease(
18326            &shared.browser_station_leases,
18327            profile.clone(),
18328            address_a.session.instance_id,
18329            1,
18330        )
18331        .expect("first lease");
18332        let busy = crate::dig2_station_lease::acquire_lease(
18333            &shared.browser_station_leases,
18334            profile.clone(),
18335            address_b.session.instance_id,
18336            2,
18337        )
18338        .expect_err("second holder must refuse");
18339        assert!(matches!(
18340            busy,
18341            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18342        ));
18343        // Simulate a bound session so remove_binding clears the lease.
18344        shared.bind_session_with_policy(
18345            &address_a,
18346            ProviderRuntimePolicy::raw_pty(),
18347            None,
18348        );
18349        guard_a.retain();
18350        assert!(shared.remove_binding(&address_a).is_some());
18351        crate::dig2_station_lease::acquire_lease(
18352            &shared.browser_station_leases,
18353            profile,
18354            address_b.session.instance_id,
18355            3,
18356        )
18357        .expect("reacquire after clear")
18358        .retain();
18359        assert!(
18360            crate::dig2_station_lease::release_lease_for_holder(
18361                &shared.browser_station_leases,
18362                address_b.session.instance_id,
18363            )
18364        );
18365    }
18366
18367    /// Feature-off: no browser_station_leases field; stub echo documented in
18368    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18369    #[cfg(not(feature = "dig2-station-probe"))]
18370    #[test]
18371    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18372        let (shared, defaults) = station_knobs_fixture();
18373        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18374        let spec = crate::protocol::SpawnSpec {
18375            target: crate::protocol::SpawnTarget {
18376                node_id: shared.node_id.clone(),
18377                workspace_id: WorkspaceId::new("primary").unwrap(),
18378                worktree_id: None,
18379            },
18380            profile_id: defaults.profile_id.clone(),
18381            expected_profile_revision: defaults.revision.clone(),
18382            overrides: crate::protocol::SpawnOverrides {
18383                browser_profile_id: Some(browser.clone()),
18384                ..crate::protocol::SpawnOverrides::default()
18385            },
18386            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18387            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18388                .unwrap(),
18389            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18390        };
18391        let resolved = spec.resolve(&defaults).unwrap();
18392        let receipt = shared
18393            .resolve_environment_profile(&resolved)
18394            .expect("stub resolve")
18395            .expect("env profile");
18396        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18397    }
18398
18399
18400    /// Startup file loader registers ids into the empty-default catalog.
18401    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18402    #[test]
18403    fn network_allowlist_catalog_loads_from_temp_file() {
18404        let unique = std::time::SystemTime::now()
18405            .duration_since(std::time::UNIX_EPOCH)
18406            .unwrap()
18407            .as_nanos();
18408        let path = std::env::temp_dir().join(format!(
18409            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18410            std::process::id(),
18411        ));
18412        std::fs::write(
18413            &path,
18414            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18415        )
18416        .unwrap();
18417        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18418            .expect("temp catalog file must load");
18419        let _ = std::fs::remove_file(&path);
18420        assert_eq!(loaded.len(), 2);
18421        let (shared, defaults) = station_knobs_fixture();
18422        {
18423            let mut catalog = shared
18424                .network_allowlist_catalog
18425                .write()
18426                .unwrap_or_else(|poisoned| poisoned.into_inner());
18427            *catalog = loaded;
18428        }
18429        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18430        let spec = crate::protocol::SpawnSpec {
18431            target: crate::protocol::SpawnTarget {
18432                node_id: shared.node_id.clone(),
18433                workspace_id: WorkspaceId::new("primary").unwrap(),
18434                worktree_id: None,
18435            },
18436            profile_id: defaults.profile_id.clone(),
18437            expected_profile_revision: defaults.revision.clone(),
18438            overrides: crate::protocol::SpawnOverrides {
18439                network_allowlist: Some(allowlist.clone()),
18440                ..crate::protocol::SpawnOverrides::default()
18441            },
18442            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18443            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18444                "station-knobs-catalog-file",
18445            )
18446            .unwrap(),
18447            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18448        };
18449        let resolved = spec.resolve(&defaults).unwrap();
18450        let receipt = shared
18451            .resolve_environment_profile(&resolved)
18452            .expect("registered via file load")
18453            .expect("env profile present");
18454        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18455    }
18456
18457    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18458    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18459    #[test]
18460    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18461        let (shared, _defaults) = station_knobs_fixture();
18462        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18463        shared
18464            .network_allowlist_catalog
18465            .write()
18466            .unwrap_or_else(|poisoned| poisoned.into_inner())
18467            .insert_id(allowlist.clone())
18468            .unwrap();
18469        let snapshot = shared.snapshot();
18470        let inventory = snapshot
18471            .launch_inventory
18472            .expect("launch inventory present");
18473        let ids = inventory
18474            .network_allowlists
18475            .expect("non-empty catalog surfaces ids");
18476        assert_eq!(ids, vec![allowlist]);
18477    }
18478
18479    /// Track B: registered id with provider_native codex_network_access refuses
18480    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18481    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18482    #[test]
18483    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18484        let (shared, defaults) = station_knobs_fixture();
18485        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18486        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18487            id: allowlist.clone(),
18488            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18489                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18490                peer: "127.0.0.1:443".to_owned(),
18491            }],
18492            provider_native: Some(
18493                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18494                    codex_network_access: Some(true),
18495                },
18496            ),
18497        };
18498        shared
18499            .network_allowlist_catalog
18500            .write()
18501            .unwrap_or_else(|poisoned| poisoned.into_inner())
18502            .insert(entry)
18503            .unwrap();
18504        let spec = crate::protocol::SpawnSpec {
18505            target: crate::protocol::SpawnTarget {
18506                node_id: shared.node_id.clone(),
18507                workspace_id: WorkspaceId::new("primary").unwrap(),
18508                worktree_id: None,
18509            },
18510            profile_id: defaults.profile_id.clone(),
18511            expected_profile_revision: defaults.revision.clone(),
18512            overrides: crate::protocol::SpawnOverrides {
18513                network_allowlist: Some(allowlist),
18514                ..crate::protocol::SpawnOverrides::default()
18515            },
18516            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18517            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18518                "station-knobs-unsupported-mapping",
18519            )
18520            .unwrap(),
18521            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18522        };
18523        let resolved = spec.resolve(&defaults).unwrap();
18524        assert_eq!(resolved.provider.as_str(), "claude");
18525        let failure = shared
18526            .resolve_environment_profile(&resolved)
18527            .expect_err("claude + codex_network_access must refuse");
18528        assert_eq!(
18529            failure.code,
18530            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18531        );
18532    }
18533
18534    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18535    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18536    /// Never secrets on C2.
18537    #[test]
18538    fn resolve_codex_network_access_overlay_moderate_only() {
18539        let (shared, defaults) = station_knobs_fixture();
18540        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18541        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18542            id: allowlist.clone(),
18543            permits: Vec::new(),
18544            provider_native: Some(
18545                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18546                    codex_network_access: Some(true),
18547                },
18548            ),
18549        };
18550        shared
18551            .network_allowlist_catalog
18552            .write()
18553            .unwrap_or_else(|poisoned| poisoned.into_inner())
18554            .insert(entry)
18555            .unwrap();
18556
18557        let overlay = shared
18558            .resolve_codex_network_access_overlay_args(
18559                &agent("codex"),
18560                SessionMode::Pty,
18561                ApprovalLevel::Moderate,
18562                Some(&allowlist),
18563            )
18564            .expect("Moderate Codex maps network_access");
18565        assert_eq!(
18566            overlay,
18567            vec![
18568                std::ffi::OsString::from("-c"),
18569                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18570            ]
18571        );
18572
18573        let acp = shared
18574            .resolve_codex_network_access_overlay_args(
18575                &agent("codex"),
18576                SessionMode::Acp,
18577                ApprovalLevel::Moderate,
18578                Some(&allowlist),
18579            )
18580            .unwrap();
18581        assert_eq!(acp, overlay);
18582
18583        let inline = shared
18584            .resolve_codex_network_access_overlay_args(
18585                &agent("codex"),
18586                SessionMode::Inline,
18587                ApprovalLevel::Moderate,
18588                Some(&allowlist),
18589            )
18590            .unwrap_err();
18591        assert_eq!(
18592            inline.code,
18593            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18594        );
18595
18596        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18597            let spec = crate::protocol::SpawnSpec {
18598                target: crate::protocol::SpawnTarget {
18599                    node_id: shared.node_id.clone(),
18600                    workspace_id: WorkspaceId::new("primary").unwrap(),
18601                    worktree_id: None,
18602                },
18603                profile_id: defaults.profile_id.clone(),
18604                expected_profile_revision: defaults.revision.clone(),
18605                overrides: crate::protocol::SpawnOverrides {
18606                    provider: crate::protocol::SpawnOverride::Set {
18607                        value: agent("codex"),
18608                    },
18609                    approval_level: Some(level),
18610                    network_allowlist: Some(allowlist.clone()),
18611                    ..crate::protocol::SpawnOverrides::default()
18612                },
18613                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18614                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18615                    "codex-net-refuse-{}",
18616                    match level {
18617                        ApprovalLevel::ReadOnly => "ro",
18618                        ApprovalLevel::FullAuto => "fa",
18619                        _ => "other",
18620                    }
18621                ))
18622                .unwrap(),
18623                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18624            };
18625            let resolved = spec.resolve(&defaults).unwrap();
18626            let failure = shared
18627                .resolve_environment_profile(&resolved)
18628                .expect_err("RO/FullAuto must refuse");
18629            assert_eq!(
18630                failure.code,
18631                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18632            );
18633        }
18634    }
18635
18636    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18637    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18638    /// Never secrets on C2; does not invent provider_native Claude keys.
18639    #[test]
18640    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18641        let (shared, _defaults) = station_knobs_fixture();
18642        let allowlist =
18643            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18644        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18645            id: allowlist.clone(),
18646            permits: vec![
18647                crate::network_allowlist_catalog::NetworkPermitSketch {
18648                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18649                    peer: "github.com:443".to_owned(),
18650                },
18651                crate::network_allowlist_catalog::NetworkPermitSketch {
18652                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18653                    peer: "*.npmjs.org".to_owned(),
18654                },
18655            ],
18656            provider_native: None,
18657        };
18658        shared
18659            .network_allowlist_catalog
18660            .write()
18661            .unwrap_or_else(|poisoned| poisoned.into_inner())
18662            .insert(entry)
18663            .unwrap();
18664
18665        let unique = std::time::SystemTime::now()
18666            .duration_since(std::time::UNIX_EPOCH)
18667            .unwrap()
18668            .as_nanos();
18669        let provider_home = std::env::temp_dir().join(format!(
18670            "gate4agent-claude-provider-home-{}-{unique}",
18671            std::process::id(),
18672        ));
18673        std::fs::create_dir_all(&provider_home).unwrap();
18674
18675        let overlay = shared
18676            .resolve_network_allowlist_overlay_args(
18677                &agent("claude"),
18678                SessionMode::Pty,
18679                ApprovalLevel::Moderate,
18680                Some(&allowlist),
18681                Some(provider_home.as_path()),
18682            )
18683            .expect("Claude PTY settings overlay on unix");
18684        let settings_path = provider_home.join(
18685            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18686        );
18687        assert_eq!(
18688            overlay,
18689            vec![
18690                std::ffi::OsString::from("--settings"),
18691                std::ffi::OsString::from(settings_path.as_os_str()),
18692            ]
18693        );
18694        let body = std::fs::read_to_string(&settings_path).unwrap();
18695        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18696        assert_eq!(value["sandbox"]["enabled"], true);
18697        assert_eq!(
18698            value["sandbox"]["network"]["allowedDomains"],
18699            serde_json::json!(["github.com", "*.npmjs.org"])
18700        );
18701        assert!(settings_path.starts_with(&provider_home));
18702
18703        let acp = shared
18704            .resolve_network_allowlist_overlay_args(
18705                &agent("claude"),
18706                SessionMode::Acp,
18707                ApprovalLevel::Moderate,
18708                Some(&allowlist),
18709                Some(provider_home.as_path()),
18710            )
18711            .expect_err("Claude ACP must refuse settings overlay");
18712        assert_eq!(
18713            acp.code,
18714            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18715        );
18716
18717        let no_home = shared
18718            .resolve_network_allowlist_overlay_args(
18719                &agent("claude"),
18720                SessionMode::Pty,
18721                ApprovalLevel::Moderate,
18722                Some(&allowlist),
18723                None,
18724            )
18725            .expect_err("Claude overlay requires ProviderHome");
18726        assert_eq!(
18727            no_home.code,
18728            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18729        );
18730
18731        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18732        shared
18733            .network_allowlist_catalog
18734            .write()
18735            .unwrap_or_else(|poisoned| poisoned.into_inner())
18736            .insert_id(bare.clone())
18737            .unwrap();
18738        let empty = shared
18739            .resolve_network_allowlist_overlay_args(
18740                &agent("claude"),
18741                SessionMode::Pty,
18742                ApprovalLevel::Moderate,
18743                Some(&bare),
18744                Some(provider_home.as_path()),
18745            )
18746            .unwrap();
18747        assert!(empty.is_empty());
18748
18749        let _ = std::fs::remove_dir_all(&provider_home);
18750    }
18751
18752    /// Kimi / Grok with permits stay without invented argv overlays.
18753    #[test]
18754    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18755        let (shared, _defaults) = station_knobs_fixture();
18756        let allowlist =
18757            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18758        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18759            id: allowlist.clone(),
18760            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18761                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18762                peer: "example.com:443".to_owned(),
18763            }],
18764            provider_native: None,
18765        };
18766        shared
18767            .network_allowlist_catalog
18768            .write()
18769            .unwrap_or_else(|poisoned| poisoned.into_inner())
18770            .insert(entry)
18771            .unwrap();
18772        for provider in ["kimi", "grok"] {
18773            let overlay = shared
18774                .resolve_network_allowlist_overlay_args(
18775                    &agent(provider),
18776                    SessionMode::Pty,
18777                    ApprovalLevel::Moderate,
18778                    Some(&allowlist),
18779                    None,
18780                )
18781                .unwrap();
18782            assert!(
18783                overlay.is_empty(),
18784                "{provider} must not invent network argv from permits"
18785            );
18786        }
18787    }
18788
18789    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18790    #[test]
18791    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18792        let unique = std::time::SystemTime::now()
18793            .duration_since(std::time::UNIX_EPOCH)
18794            .unwrap()
18795            .as_nanos();
18796        let path = std::env::temp_dir().join(format!(
18797            "gate4agent-node-allowlist-json-{}-{unique}.json",
18798            std::process::id(),
18799        ));
18800        std::fs::write(
18801            &path,
18802            r#"{
18803              "schema_version": 2,
18804              "entries": [
18805                {
18806                  "id": "egress-default",
18807                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18808                },
18809                { "id": "lab-egress" }
18810              ]
18811            }"#,
18812        )
18813        .unwrap();
18814        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18815            .expect("json v2 catalog must load");
18816        let _ = std::fs::remove_file(&path);
18817        assert_eq!(loaded.len(), 2);
18818        let entry = loaded
18819            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18820            .unwrap();
18821        assert_eq!(entry.permits.len(), 1);
18822        let (shared, defaults) = station_knobs_fixture();
18823        {
18824            let mut catalog = shared
18825                .network_allowlist_catalog
18826                .write()
18827                .unwrap_or_else(|poisoned| poisoned.into_inner());
18828            *catalog = loaded;
18829        }
18830        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18831        let spec = crate::protocol::SpawnSpec {
18832            target: crate::protocol::SpawnTarget {
18833                node_id: shared.node_id.clone(),
18834                workspace_id: WorkspaceId::new("primary").unwrap(),
18835                worktree_id: None,
18836            },
18837            profile_id: defaults.profile_id.clone(),
18838            expected_profile_revision: defaults.revision.clone(),
18839            overrides: crate::protocol::SpawnOverrides {
18840                network_allowlist: Some(allowlist.clone()),
18841                ..crate::protocol::SpawnOverrides::default()
18842            },
18843            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18844            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18845                "station-knobs-catalog-json",
18846            )
18847            .unwrap(),
18848            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18849        };
18850        let resolved = spec.resolve(&defaults).unwrap();
18851        let receipt = shared
18852            .resolve_environment_profile(&resolved)
18853            .expect("permits-only entry ok for claude")
18854            .expect("env profile present");
18855        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18856        let inventory = shared
18857            .snapshot()
18858            .launch_inventory
18859            .expect("launch inventory present");
18860        let ids = inventory.network_allowlists.expect("ids surface");
18861        assert_eq!(
18862            ids,
18863            vec![
18864                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18865                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18866            ]
18867        );
18868    }
18869
18870
18871}
18872
18873
18874#[cfg(test)]
18875mod workspace_environment_collision_tests {
18876    use super::*;
18877    use crate::session_environment::{
18878        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18879    };
18880    use gate4agent_catalog::EnvMutation;
18881    use gate4agent_runtime_native::{
18882        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18883        NativeLaunchProfileId,
18884    };
18885    use std::ffi::OsString;
18886    use std::sync::Arc;
18887
18888    struct EmptyEnvironmentResolver;
18889
18890    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18891        fn resolve_child_environment(
18892            &self,
18893        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18894            Ok(vec![EnvMutation {
18895                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18896                value: None,
18897            }])
18898        }
18899    }
18900
18901    fn agent(value: &str) -> AgentId {
18902        AgentId::new(value).unwrap()
18903    }
18904
18905    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18906    fn claude_config_dir_collision_fixture() -> (
18907        NodeShared,
18908        ResolvedEnvironmentProfileReceipt,
18909        ResolvedEnvironmentProfileReceipt,
18910    ) {
18911        let catalog = active_registry().unwrap();
18912        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18913        let control = runtime.native_launch_profile_control();
18914        let workspace = WorkspaceConfig::new(
18915            WorkspaceId::new("primary").unwrap(),
18916            std::env::current_dir().unwrap(),
18917        )
18918        .unwrap();
18919        let shared = NodeShared::new_with_incarnation(
18920            handle,
18921            "fixture-token".to_owned(),
18922            NodeId::new("node-env-collision-test").unwrap(),
18923            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18924            vec![workspace],
18925            vec![agent("claude")],
18926            ProviderRuntimeStatuses::default(),
18927            None,
18928            Vec::new(),
18929            Vec::new(),
18930            SpawnProfileRegistry::default(),
18931            Some(control),
18932            None,
18933            None,
18934            None,
18935            Vec::new(),
18936            Vec::new(),
18937            Vec::new(),
18938            Vec::new(),
18939            None,
18940            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18941        );
18942
18943        let mut receipts = Vec::new();
18944        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18945            let profile_id =
18946                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18947            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18948                "claude-config-dir-{suffix}-r1"
18949            ))
18950            .unwrap();
18951            let materialization = NodeSessionMaterializationProfile::new(
18952                Vec::new(),
18953                vec![NodeSessionPathBinding::new(
18954                    "CLAUDE_CONFIG_DIR",
18955                    NodeSessionPathClass::ProviderHome,
18956                )
18957                .unwrap()],
18958                Vec::new(),
18959            )
18960            .unwrap();
18961            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18962                profile_id.clone(),
18963                profile_revision.clone(),
18964                agent("claude"),
18965                [NativeLaunchProfile::new(
18966                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18967                        .unwrap(),
18968                    agent("claude"),
18969                    TransportKind::Pty,
18970                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18971                    Arc::new(EmptyEnvironmentResolver),
18972                )
18973                .unwrap()],
18974                Some(materialization),
18975            )
18976            .unwrap();
18977            let (binding, native_profiles, materialization) = node_profile.into_parts();
18978            for native_profile in native_profiles {
18979                runtime.upsert_native_launch_profile(native_profile).unwrap();
18980            }
18981            shared
18982                .environment_profiles
18983                .write()
18984                .unwrap_or_else(|poisoned| poisoned.into_inner())
18985                .insert(binding.id.clone(), binding);
18986            shared
18987                .environment_materialization_profiles
18988                .write()
18989                .unwrap_or_else(|poisoned| poisoned.into_inner())
18990                .insert(profile_id.clone(), materialization.unwrap());
18991            receipts.push(ResolvedEnvironmentProfileReceipt {
18992                profile_id,
18993                profile_revision,
18994                network_allowlist: None,
18995                browser_profile_id: None,
18996            });
18997        }
18998        let profile_b = receipts.pop().unwrap();
18999        let profile_a = receipts.pop().unwrap();
19000        (shared, profile_a, profile_b)
19001    }
19002
19003    #[test]
19004    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19005        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19006        let primary = WorkspaceId::new("primary").unwrap();
19007        let worktree = WorkspaceId::new("worktree-b").unwrap();
19008        let address = SessionAddress {
19009            workspace_id: primary.clone(),
19010            session: SessionKey {
19011                instance_id: AgentInstanceId(41),
19012                generation: SessionGeneration(1),
19013            },
19014        };
19015        shared.bind_session_with_policy(
19016            &address,
19017            ProviderRuntimePolicy::raw_pty(),
19018            Some(profile_a.clone()),
19019        );
19020
19021        let refused = shared
19022            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19023            .unwrap_err();
19024        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19025
19026        // Some vs None on the same cwd is also a collision.
19027        let refused_none = shared
19028            .ensure_workspace_environment_profile_compatible(&primary, None)
19029            .unwrap_err();
19030        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19031
19032        // Same profile on the same workspace remains allowed.
19033        shared
19034            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19035            .unwrap();
19036
19037        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19038        shared
19039            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19040            .unwrap();
19041    }
19042
19043    #[tokio::test]
19044    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19045        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19046        let primary = WorkspaceId::new("primary").unwrap();
19047        let address = SessionAddress {
19048            workspace_id: primary.clone(),
19049            session: SessionKey {
19050                instance_id: AgentInstanceId(41),
19051                generation: SessionGeneration(1),
19052            },
19053        };
19054        shared.bind_session_with_policy(
19055            &address,
19056            ProviderRuntimePolicy::raw_pty(),
19057            Some(profile_a),
19058        );
19059
19060        let failure = shared
19061            .spawn_session_with_deadline(
19062                primary,
19063                agent("claude"),
19064                SessionMode::Pty,
19065                gate4agent_types::TerminalSize {
19066                    rows: 24,
19067                    columns: 80,
19068                },
19069                None,
19070                Some(profile_b),
19071                None,
19072                None,
19073                None,
19074                None,
19075                SpawnRecordPolicy::ProviderIdentityOnly,
19076                Some(Instant::now() + Duration::from_secs(30)),
19077                &[],
19078                None,
19079                ApprovalLevel::default(),
19080                None,
19081                None,
19082            )
19083            .await
19084            .unwrap_err();
19085        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19086        // Guard runs before instance id mint / bind.
19087        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19088        assert!(shared.handle.snapshot().sessions.is_empty());
19089    }
19090}
19091
19092#[cfg(test)]
19093mod spawn_characterization_tests {
19094    use super::*;
19095    use crate::session_environment::{
19096        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19097        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19098        NodeSessionPathClass,
19099    };
19100    use gate4agent_catalog::EnvMutation;
19101    use gate4agent_runtime_native::{
19102        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19103        NativeLaunchProfile, NativeLaunchProfileId,
19104    };
19105    use std::ffi::OsString;
19106    use std::sync::Arc;
19107
19108    struct EmptyEnvironmentResolver;
19109
19110    struct FixtureSecretResolver {
19111        deny: Arc<AtomicBool>,
19112    }
19113
19114    impl NodeSecretResolver for FixtureSecretResolver {
19115        fn resolve(
19116            &self,
19117            _reference: &NodeSecretReference,
19118        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19119            if self.deny.load(Ordering::Acquire) {
19120                Err(NodeSecretResolveError::Denied)
19121            } else {
19122                NodeSecretValue::text("fixture-secret")
19123                    .map_err(|_| NodeSecretResolveError::Unavailable)
19124            }
19125        }
19126    }
19127
19128    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19129        fn resolve_child_environment(
19130            &self,
19131        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19132            Ok(vec![EnvMutation {
19133                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19134                value: None,
19135            }])
19136        }
19137    }
19138
19139    fn agent(value: &str) -> AgentId {
19140        AgentId::new(value).unwrap()
19141    }
19142
19143    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19144        let unique = std::time::SystemTime::now()
19145            .duration_since(std::time::UNIX_EPOCH)
19146            .unwrap()
19147            .as_nanos();
19148        std::env::temp_dir().join(format!(
19149            "gate4agent-node-{label}-{}-{unique}",
19150            std::process::id(),
19151        ))
19152    }
19153
19154    fn materialization_test_shared(
19155        deny_secret: bool,
19156    ) -> (
19157        NodeShared,
19158        NativeRuntime,
19159        ResolvedEnvironmentProfileReceipt,
19160        PathBuf,
19161        Arc<AtomicBool>,
19162    ) {
19163        let workspace = WorkspaceConfig::new(
19164            WorkspaceId::new("primary").unwrap(),
19165            std::env::current_dir().unwrap(),
19166        )
19167        .unwrap();
19168        materialization_test_shared_with_workspace(deny_secret, workspace)
19169    }
19170
19171    fn materialization_test_shared_with_workspace(
19172        deny_secret: bool,
19173        workspace: WorkspaceConfig,
19174    ) -> (
19175        NodeShared,
19176        NativeRuntime,
19177        ResolvedEnvironmentProfileReceipt,
19178        PathBuf,
19179        Arc<AtomicBool>,
19180    ) {
19181        let root = temporary_workspace_root(if deny_secret {
19182            "materialization-denied"
19183        } else {
19184            "materialization-ready"
19185        });
19186        std::fs::create_dir_all(&root).unwrap();
19187        let materialization_root = root.join("session-environments");
19188        let deny = Arc::new(AtomicBool::new(deny_secret));
19189        let resolver = Arc::new(FixtureSecretResolver {
19190            deny: Arc::clone(&deny),
19191        });
19192        let materializer = SessionEnvironmentMaterializer::new(
19193            materialization_root,
19194            resolver,
19195        )
19196        .unwrap();
19197        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19198        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19199            "materialized-claude-r1",
19200        )
19201        .unwrap();
19202        let materialization = NodeSessionMaterializationProfile::new(
19203            vec![NodeSessionEnvironmentMutation::SetSecret {
19204                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19205                reference: NodeSecretReference::new("fixture-token").unwrap(),
19206            }],
19207            vec![NodeSessionPathBinding::new(
19208                "GATE4AGENT_PROVIDER_HOME",
19209                NodeSessionPathClass::ProviderHome,
19210            )
19211            .unwrap()],
19212            vec![NodeSessionFile::secret(
19213                NodeSessionPathClass::Config,
19214                "auth/token",
19215                NodeSecretReference::new("fixture-token").unwrap(),
19216            )
19217            .unwrap()],
19218        )
19219        .unwrap();
19220        let catalog = active_registry().unwrap();
19221        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19222        let control = runtime.native_launch_profile_control();
19223        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19224            profile_id.clone(),
19225            profile_revision.clone(),
19226            agent("claude"),
19227            [NativeLaunchProfile::new(
19228                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19229                agent("claude"),
19230                TransportKind::Pty,
19231                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19232                Arc::new(EmptyEnvironmentResolver),
19233            )
19234            .unwrap()],
19235            Some(materialization),
19236        )
19237        .unwrap();
19238        let (binding, native_profiles, materialization) = node_profile.into_parts();
19239        for native_profile in native_profiles {
19240            runtime.upsert_native_launch_profile(native_profile).unwrap();
19241        }
19242        let shared = NodeShared::new_with_incarnation(
19243            handle,
19244            "fixture-token".to_owned(),
19245            NodeId::new("node-materialization-test").unwrap(),
19246            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19247            vec![workspace],
19248            vec![agent("claude")],
19249            ProviderRuntimeStatuses::default(),
19250            None,
19251            Vec::new(),
19252            Vec::new(),
19253            SpawnProfileRegistry::default(),
19254            Some(control),
19255            None,
19256            Some(materializer),
19257            None,
19258            Vec::new(),
19259            Vec::new(),
19260            Vec::new(),
19261            Vec::new(),
19262            None,
19263            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19264        );
19265        shared
19266            .environment_profiles
19267            .write()
19268            .unwrap_or_else(|poisoned| poisoned.into_inner())
19269            .insert(binding.id.clone(), binding);
19270        shared
19271            .environment_materialization_profiles
19272            .write()
19273            .unwrap_or_else(|poisoned| poisoned.into_inner())
19274            .insert(profile_id.clone(), materialization.unwrap());
19275        (
19276            shared,
19277            runtime,
19278            ResolvedEnvironmentProfileReceipt {
19279                profile_id,
19280                profile_revision,
19281                network_allowlist: None,
19282                browser_profile_id: None,
19283            },
19284            root,
19285            deny,
19286        )
19287    }
19288
19289
19290    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19291
19292    async fn characterization_spawn(
19293        shared: &NodeShared,
19294        mode: SessionMode,
19295        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19296        required: &[ProviderRuntimeCapability],
19297        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19298    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19299        shared
19300            .spawn_session_with_deadline(
19301                WorkspaceId::new("primary").unwrap(),
19302                agent("claude"),
19303                mode,
19304                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19305                None,
19306                environment_profile,
19307                None,
19308                None,
19309                None,
19310                Some(ProviderRuntimePolicy::raw_pty()),
19311                SpawnRecordPolicy::ProviderIdentityOnly,
19312                Some(Instant::now() + Duration::from_secs(30)),
19313                required,
19314                harness_mcp,
19315                ApprovalLevel::default(),
19316                None,
19317                None,
19318            )
19319            .await
19320    }
19321
19322    fn assert_no_session_state(shared: &NodeShared) {
19323        assert!(shared.handle.snapshot().sessions.is_empty());
19324        assert!(shared
19325            .session_bindings
19326            .lock()
19327            .unwrap_or_else(|poisoned| poisoned.into_inner())
19328            .is_empty());
19329        assert!(shared
19330            .materializations
19331            .lock()
19332            .unwrap_or_else(|poisoned| poisoned.into_inner())
19333            .is_empty());
19334    }
19335
19336    #[tokio::test]
19337    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19338        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19339        let error = characterization_spawn(
19340            &shared,
19341            SessionMode::Pty,
19342            None,
19343            &[ProviderRuntimeCapability::SemanticReadiness],
19344            None,
19345        )
19346        .await
19347        .unwrap_err();
19348        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19349        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19350        assert_no_session_state(&shared);
19351        drop(shared);
19352        drop(runtime);
19353        std::fs::remove_dir_all(root).unwrap();
19354    }
19355
19356    #[tokio::test]
19357    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19358        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19359        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19360            .await
19361            .unwrap_err();
19362        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19363        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19364        assert_no_session_state(&shared);
19365        drop(shared);
19366        drop(runtime);
19367        std::fs::remove_dir_all(root).unwrap();
19368    }
19369
19370    #[test]
19371    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19372        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19373        let address = SessionAddress {
19374            workspace_id: WorkspaceId::new("primary").unwrap(),
19375            session: SessionKey {
19376                instance_id: AgentInstanceId(77),
19377                generation: SessionGeneration(1),
19378            },
19379        };
19380        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19381        let removed = shared.remove_binding(&address).expect("binding removed");
19382        assert!(removed.environment_profile.is_some());
19383        assert!(shared.remove_binding(&address).is_none());
19384        assert_no_session_state(&shared);
19385        drop(shared);
19386        drop(runtime);
19387        std::fs::remove_dir_all(root).unwrap();
19388    }
19389
19390    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19391        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19392        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19393        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19394            .expect("review current test executable as fixture helper");
19395        let registry = HarnessMcpProxyRegistry::new(helper);
19396        let spec = crate::protocol::SpawnSpec {
19397            target: crate::protocol::SpawnTarget {
19398                node_id: NodeId::new("node-characterization").unwrap(),
19399                workspace_id: WorkspaceId::new("primary").unwrap(),
19400                worktree_id: None,
19401            },
19402            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19403            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19404            overrides: crate::protocol::SpawnOverrides::default(),
19405            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19406            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19407            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19408        };
19409        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19410        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19411        let launch = HarnessMcpLaunchV1 {
19412            server_name: "fixture-harness".to_owned(),
19413            args: vec!["--session-proxy".to_owned()],
19414            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19415            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19416            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19417            trace: None,
19418            scrub_env: Vec::new(),
19419        };
19420        let expires = unix_time_ms().saturating_add(30_000);
19421        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19422        registry
19423            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19424            .unwrap()
19425    }
19426
19427    #[tokio::test]
19428    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19429        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19430        let prepared = characterization_prepared_harness("claude").await;
19431        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19432            .await
19433            .unwrap_err();
19434        // Inline is stopped by runtime admission before the harness check runs.
19435        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19436        let mismatched = characterization_prepared_harness("codex").await;
19437        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19438            .await
19439            .unwrap_err();
19440        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19441        assert_no_session_state(&shared);
19442        drop(shared);
19443        drop(runtime);
19444        std::fs::remove_dir_all(root).unwrap();
19445    }
19446
19447    #[tokio::test]
19448    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19449        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19450        shared.native_launch_profile_control = None;
19451        let prepared = characterization_prepared_harness("claude").await;
19452        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19453            .await
19454            .unwrap_err();
19455        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19456        assert_no_session_state(&shared);
19457        drop(shared);
19458        drop(runtime);
19459        std::fs::remove_dir_all(root).unwrap();
19460    }
19461}
19462
19463#[cfg(all(test, windows))]
19464mod tests {
19465    use super::*;
19466    use gate4agent_types::ProviderActivity;
19467    use crate::session_environment::{
19468        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19469        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19470        NodeSessionPathClass,
19471    };
19472    use gate4agent_catalog::EnvMutation;
19473    use gate4agent_runtime_native::{
19474        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19475        NativeLaunchProfile, NativeLaunchProfileId,
19476    };
19477    use std::ffi::OsString;
19478    use std::sync::Arc;
19479
19480    struct EmptyEnvironmentResolver;
19481
19482    struct FixtureSecretResolver {
19483        deny: Arc<AtomicBool>,
19484    }
19485
19486    impl NodeSecretResolver for FixtureSecretResolver {
19487        fn resolve(
19488            &self,
19489            _reference: &NodeSecretReference,
19490        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19491            if self.deny.load(Ordering::Acquire) {
19492                Err(NodeSecretResolveError::Denied)
19493            } else {
19494                NodeSecretValue::text("fixture-secret")
19495                    .map_err(|_| NodeSecretResolveError::Unavailable)
19496            }
19497        }
19498    }
19499
19500    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19501        fn resolve_child_environment(
19502            &self,
19503        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19504            Ok(vec![EnvMutation {
19505                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19506                value: None,
19507            }])
19508        }
19509    }
19510
19511    fn agent(value: &str) -> AgentId {
19512        AgentId::new(value).unwrap()
19513    }
19514
19515    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19516        let catalog = active_registry().unwrap();
19517        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19518        let workspace = WorkspaceConfig::new(
19519            WorkspaceId::new("primary").unwrap(),
19520            std::env::current_dir().unwrap(),
19521        )
19522        .unwrap()
19523        .with_worktree_service_mode(mode);
19524        NodeShared::new(
19525            handle,
19526            "fixture-token".to_owned(),
19527            NodeId::new("node-terminal-test").unwrap(),
19528            vec![workspace],
19529            vec![agent("claude")],
19530        )
19531    }
19532
19533    fn terminal_test_shared() -> NodeShared {
19534        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19535    }
19536
19537    #[cfg(feature = "fixture")]
19538    #[test]
19539    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19540        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19541        assert_eq!(probe.latest_code(), None);
19542
19543        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19544        assert_eq!(
19545            probe.latest_code(),
19546            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19547        );
19548
19549        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19550        assert_eq!(
19551            probe.latest_code(),
19552            Some(NodeFailureCode::SpawnDeadlineExceeded),
19553        );
19554
19555        probe.record(None);
19556        assert_eq!(probe.latest_code(), None);
19557    }
19558
19559    #[test]
19560    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19561        let source = temporary_workspace_root("launch-inventory-source");
19562        let allocation = temporary_workspace_root("launch-inventory-allocation");
19563        std::fs::create_dir_all(&source).unwrap();
19564        std::fs::create_dir_all(&allocation).unwrap();
19565        let workspace_id = WorkspaceId::new("primary").unwrap();
19566        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19567            .unwrap()
19568            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19569        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19570            workspace = workspace
19571                .with_managed_worktree_profile(
19572                    ManagedWorktreeProfile::new(
19573                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19574                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19575                        &allocation,
19576                        "gate4agent",
19577                        "HEAD",
19578                        ManagedWorktreeRetention::RemoveWhenReleased,
19579                    )
19580                    .unwrap(),
19581                )
19582                .unwrap();
19583        }
19584        let overflow = workspace.clone().with_managed_worktree_profile(
19585            ManagedWorktreeProfile::new(
19586                WorktreeProfileId::new("overflow").unwrap(),
19587                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19588                &allocation,
19589                "gate4agent",
19590                "HEAD",
19591                ManagedWorktreeRetention::Retain,
19592            )
19593            .unwrap(),
19594        );
19595        assert!(matches!(
19596            overflow,
19597            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19598                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19599                ..
19600            }),
19601        ));
19602
19603        let catalog = active_registry().unwrap();
19604        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19605        let shared = NodeShared::new(
19606            handle,
19607            "fixture-token".to_owned(),
19608            NodeId::new("node-launch-inventory").unwrap(),
19609            vec![workspace],
19610            vec![agent("claude")],
19611        );
19612        let snapshot = shared.snapshot();
19613        let launch = snapshot.launch_inventory.as_ref().unwrap();
19614        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19615        assert!(launch.bundles.as_ref().unwrap().is_empty());
19616        let profiles = snapshot.workspaces[0]
19617            .managed_worktree_profiles
19618            .as_ref()
19619            .unwrap();
19620        assert_eq!(
19621            snapshot.workspaces[0].worktree_service_mode,
19622            Some(WorktreeServiceMode::Managed),
19623        );
19624        assert_eq!(
19625            profiles.profiles.len(),
19626            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19627        );
19628        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19629        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19630            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19631        }
19632        let legacy = snapshot_for_wire(
19633            &shared,
19634            true,
19635            true,
19636            false,
19637            false,
19638            true,
19639            false,
19640            true,
19641            false,
19642            true,
19643        );
19644        assert!(legacy.launch_inventory.is_none());
19645        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19646
19647        drop(shared);
19648        std::fs::remove_dir_all(source).unwrap();
19649        std::fs::remove_dir_all(allocation).unwrap();
19650    }
19651
19652    #[test]
19653    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19654        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19655            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19656        }));
19657        let source = gate4agent_types::ProviderSource {
19658            family: AdapterFamily::PtySemantic,
19659            binding: AdapterBinding::new(
19660                gate4agent_types::AdapterId::new("claude").unwrap(),
19661                "fixture/v1",
19662                gate4agent_types::AdapterVerification::SyntheticFixture,
19663            )
19664            .unwrap(),
19665        };
19666        let provider = ProviderSnapshot {
19667            sequence: 17,
19668            session: Some(ProviderSessionIdentity {
19669                key: ProviderSessionKey::SessionId,
19670                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19671                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19672            }),
19673            model: Some("PRIVATE_MODEL".to_owned()),
19674            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19675            completed_turns: 3,
19676            usage: gate4agent_types::TokenUsage {
19677                input_tokens: 10,
19678                output_tokens: 20,
19679                cache_read_tokens: 30,
19680                cache_write_tokens: 40,
19681                reasoning_tokens: 50,
19682                context_window: Some(200_000),
19683            },
19684            lead_activity: ProviderActivity::WaitingForInput,
19685            activity: ProviderActivity::WaitingForInput,
19686            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19687            active_tools: (0..9)
19688                .map(|index| gate4agent_types::ActiveProviderTool {
19689                    id: format!("private-tool-id-{index}"),
19690                    name: if index == 0 {
19691                        format!("  Read\u{0000}{}", "x".repeat(80))
19692                    } else {
19693                        format!("Tool-{index}")
19694                    },
19695                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19696                })
19697                .collect(),
19698            interactions: vec![gate4agent_types::ProviderInteraction {
19699                id: gate4agent_types::ProviderInteractionId(17),
19700                source: source.clone(),
19701                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19702                interaction_kind: ProviderInteractionKind::Approval,
19703                tool_name: " Approve\u{0007}Tool ".to_owned(),
19704                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19705                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19706                resume_lead_activity: Some(ProviderActivity::Working),
19707                status: ProviderInteractionStatus::Pending,
19708            }],
19709            subagents: vec![gate4agent_types::ProviderSubagent {
19710                source: source.clone(),
19711                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19712                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19713                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19714            }],
19715            sources: vec![gate4agent_types::ProviderSourceCursor {
19716                source,
19717                sequence: 17,
19718                gap_count: 2,
19719                stale: true,
19720            }],
19721            last_event: Some(ProviderEvent::Error {
19722                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19723            }),
19724            gap_count: 2,
19725            stale: true,
19726        };
19727        let address = terminal_address(3);
19728        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19729        assert_eq!(entry.address, address);
19730        assert_eq!(entry.progress.provider_sequence, 17);
19731        assert_eq!(entry.progress.active_tool_count, 9);
19732        assert_eq!(
19733            entry.progress.active_tool_labels.len(),
19734            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19735        );
19736        assert_eq!(entry.progress.subagent_count, 1);
19737        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19738        assert!(entry.progress.truncated);
19739        let encoded = serde_json::to_string(&entry).unwrap();
19740        for forbidden in [
19741            "PRIVATE_PROVIDER_SESSION",
19742            "PRIVATE_PROVIDER_PATH",
19743            "PRIVATE_MODEL",
19744            "PRIVATE_TOOL_INVENTORY",
19745            "PRIVATE_CURRENT_PROMPT",
19746            "PRIVATE_TOOL_INPUT_JSON",
19747            "PRIVATE_PROVIDER_REQUEST",
19748            "PRIVATE_INTERACTION_PROMPT",
19749            "PRIVATE_INTERACTION_AGENT",
19750            "PRIVATE_SUBAGENT_ID",
19751            "PRIVATE_SUBAGENT_TYPE",
19752            "PRIVATE_SUBAGENT_DESCRIPTION",
19753            "PRIVATE_PROVIDER_ERROR",
19754            "input_json",
19755            "current_prompt",
19756            "session_id",
19757        ] {
19758            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19759        }
19760
19761        let snapshot = NodeSnapshot {
19762            node_id: NodeId::new("node-progress-test").unwrap(),
19763            enabled_providers: vec![agent("claude")],
19764            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19765            workspaces: Vec::new(),
19766            session_records: Vec::new(),
19767            managed_worktrees: Vec::new(),
19768            launch_inventory: None,
19769            agent_progress: vec![entry],
19770        };
19771        let mut legacy_reply = ResponseEnvelope {
19772            request_id: 1,
19773            result: Ok(NodeResponse::Snapshot {
19774                event_sequence: 0,
19775                controller: None,
19776                snapshot: snapshot.clone(),
19777            }),
19778        };
19779        project_response_without_agent_progress(&mut legacy_reply);
19780        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19781            panic!("agent progress projection changed response kind");
19782        };
19783        assert!(legacy.agent_progress.is_empty());
19784        assert_eq!(snapshot.agent_progress.len(), 1);
19785    }
19786
19787    fn terminal_address(generation: u64) -> SessionAddress {
19788        SessionAddress {
19789            workspace_id: WorkspaceId::new("primary").unwrap(),
19790            session: SessionKey {
19791                instance_id: AgentInstanceId(41),
19792                generation: SessionGeneration(generation),
19793            },
19794        }
19795    }
19796
19797    fn spawn_spec_fixture() -> SpawnSpec {
19798        SpawnSpec {
19799            target: crate::protocol::SpawnTarget {
19800                node_id: NodeId::new("node-terminal-test").unwrap(),
19801                workspace_id: WorkspaceId::new("primary").unwrap(),
19802                worktree_id: None,
19803            },
19804            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19805            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19806                .unwrap(),
19807            overrides: crate::protocol::SpawnOverrides::default(),
19808            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19809            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19810            required_capabilities: SpawnRequiredCapabilities::default(),
19811        }
19812    }
19813
19814    fn environment_profile_test_shared(
19815    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19816        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19817        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19818            "local-claude-r1",
19819        )
19820        .unwrap();
19821        let spawn_profiles = SpawnProfileRegistry::new([
19822            crate::protocol::SpawnProfileDefaults {
19823                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19824                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19825                provider: agent("claude"),
19826                mode: SessionMode::Pty,
19827                terminal_size: gate4agent_types::TerminalSize {
19828                    rows: 24,
19829                    columns: 80,
19830                },
19831                prompt: None,
19832                bundle_id: None,
19833                context_id: None,
19834                environment_profile_id: Some(profile_id.clone()),
19835            },
19836        ])
19837        .unwrap();
19838        let catalog = active_registry().unwrap();
19839        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19840        let control = runtime.native_launch_profile_control();
19841        let node_profile = NodeEnvironmentProfile::new(
19842            profile_id.clone(),
19843            profile_revision.clone(),
19844            agent("claude"),
19845            [NativeLaunchProfile::new(
19846                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19847                agent("claude"),
19848                TransportKind::Pty,
19849                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19850                Arc::new(EmptyEnvironmentResolver),
19851            )
19852            .unwrap()],
19853        )
19854        .unwrap();
19855        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19856        for native_profile in native_profiles {
19857            runtime.upsert_native_launch_profile(native_profile).unwrap();
19858        }
19859        let workspace = WorkspaceConfig::new(
19860            WorkspaceId::new("primary").unwrap(),
19861            std::env::current_dir().unwrap(),
19862        )
19863        .unwrap();
19864        let shared = NodeShared::new_with_incarnation(
19865            handle,
19866            "fixture-token".to_owned(),
19867            NodeId::new("node-terminal-test").unwrap(),
19868            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19869            vec![workspace],
19870            vec![agent("claude")],
19871            ProviderRuntimeStatuses::default(),
19872            None,
19873            Vec::new(),
19874            Vec::new(),
19875            spawn_profiles,
19876            Some(control),
19877            None,
19878            None,
19879            None,
19880            Vec::new(),
19881            Vec::new(),
19882            Vec::new(),
19883            Vec::new(),
19884            None,
19885            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19886        );
19887        shared
19888            .environment_profiles
19889            .write()
19890            .unwrap_or_else(|poisoned| poisoned.into_inner())
19891            .insert(binding.id.clone(), binding);
19892        (
19893            shared,
19894            runtime,
19895            ResolvedEnvironmentProfileReceipt {
19896                profile_id,
19897                profile_revision,
19898                network_allowlist: None,
19899                browser_profile_id: None,
19900            },
19901        )
19902    }
19903
19904    fn materialization_test_shared(
19905        deny_secret: bool,
19906    ) -> (
19907        NodeShared,
19908        NativeRuntime,
19909        ResolvedEnvironmentProfileReceipt,
19910        PathBuf,
19911        Arc<AtomicBool>,
19912    ) {
19913        let workspace = WorkspaceConfig::new(
19914            WorkspaceId::new("primary").unwrap(),
19915            std::env::current_dir().unwrap(),
19916        )
19917        .unwrap();
19918        materialization_test_shared_with_workspace(deny_secret, workspace)
19919    }
19920
19921    fn materialization_test_shared_with_workspace(
19922        deny_secret: bool,
19923        workspace: WorkspaceConfig,
19924    ) -> (
19925        NodeShared,
19926        NativeRuntime,
19927        ResolvedEnvironmentProfileReceipt,
19928        PathBuf,
19929        Arc<AtomicBool>,
19930    ) {
19931        let root = temporary_workspace_root(if deny_secret {
19932            "materialization-denied"
19933        } else {
19934            "materialization-ready"
19935        });
19936        std::fs::create_dir_all(&root).unwrap();
19937        let materialization_root = root.join("session-environments");
19938        let deny = Arc::new(AtomicBool::new(deny_secret));
19939        let resolver = Arc::new(FixtureSecretResolver {
19940            deny: Arc::clone(&deny),
19941        });
19942        let materializer = SessionEnvironmentMaterializer::new(
19943            materialization_root,
19944            resolver,
19945        )
19946        .unwrap();
19947        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19948        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19949            "materialized-claude-r1",
19950        )
19951        .unwrap();
19952        let materialization = NodeSessionMaterializationProfile::new(
19953            vec![NodeSessionEnvironmentMutation::SetSecret {
19954                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19955                reference: NodeSecretReference::new("fixture-token").unwrap(),
19956            }],
19957            vec![NodeSessionPathBinding::new(
19958                "GATE4AGENT_PROVIDER_HOME",
19959                NodeSessionPathClass::ProviderHome,
19960            )
19961            .unwrap()],
19962            vec![NodeSessionFile::secret(
19963                NodeSessionPathClass::Config,
19964                "auth/token",
19965                NodeSecretReference::new("fixture-token").unwrap(),
19966            )
19967            .unwrap()],
19968        )
19969        .unwrap();
19970        let catalog = active_registry().unwrap();
19971        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19972        let control = runtime.native_launch_profile_control();
19973        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19974            profile_id.clone(),
19975            profile_revision.clone(),
19976            agent("claude"),
19977            [NativeLaunchProfile::new(
19978                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19979                agent("claude"),
19980                TransportKind::Pty,
19981                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19982                Arc::new(EmptyEnvironmentResolver),
19983            )
19984            .unwrap()],
19985            Some(materialization),
19986        )
19987        .unwrap();
19988        let (binding, native_profiles, materialization) = node_profile.into_parts();
19989        for native_profile in native_profiles {
19990            runtime.upsert_native_launch_profile(native_profile).unwrap();
19991        }
19992        let shared = NodeShared::new_with_incarnation(
19993            handle,
19994            "fixture-token".to_owned(),
19995            NodeId::new("node-materialization-test").unwrap(),
19996            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19997            vec![workspace],
19998            vec![agent("claude")],
19999            ProviderRuntimeStatuses::default(),
20000            None,
20001            Vec::new(),
20002            Vec::new(),
20003            SpawnProfileRegistry::default(),
20004            Some(control),
20005            None,
20006            Some(materializer),
20007            None,
20008            Vec::new(),
20009            Vec::new(),
20010            Vec::new(),
20011            Vec::new(),
20012            None,
20013            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20014        );
20015        shared
20016            .environment_profiles
20017            .write()
20018            .unwrap_or_else(|poisoned| poisoned.into_inner())
20019            .insert(binding.id.clone(), binding);
20020        shared
20021            .environment_materialization_profiles
20022            .write()
20023            .unwrap_or_else(|poisoned| poisoned.into_inner())
20024            .insert(profile_id.clone(), materialization.unwrap());
20025        (
20026            shared,
20027            runtime,
20028            ResolvedEnvironmentProfileReceipt {
20029                profile_id,
20030                profile_revision,
20031                network_allowlist: None,
20032                browser_profile_id: None,
20033            },
20034            root,
20035            deny,
20036        )
20037    }
20038
20039    #[cfg(feature = "fixture")]
20040    #[derive(Clone, Copy)]
20041    enum CodexHomeProfile {
20042        Missing,
20043        WrongClass,
20044        Exact,
20045    }
20046
20047    #[cfg(feature = "fixture")]
20048    fn codex_bundle_test_shared(
20049        home_profile: CodexHomeProfile,
20050    ) -> (
20051        NodeShared,
20052        NativeRuntime,
20053        ResolvedEnvironmentProfileReceipt,
20054        NodeBundle,
20055        PathBuf,
20056    ) {
20057        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20058        const CLAUDE_MANIFEST: &[u8] =
20059            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20060        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20061        const DIGEST: &str =
20062            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20063
20064        let (shared, mut runtime, _claude_receipt, root, _deny) =
20065            materialization_test_shared(false);
20066        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20067            CodexHomeProfile::Missing => "codex-home-missing",
20068            CodexHomeProfile::WrongClass => "codex-home-wrong",
20069            CodexHomeProfile::Exact => "codex-home-exact",
20070        })
20071        .unwrap();
20072        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20073            "codex-home-r1",
20074        )
20075        .unwrap();
20076        let materialization = match home_profile {
20077            CodexHomeProfile::Missing => None,
20078            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20079                Some(NodeSessionMaterializationProfile::new(
20080                    Vec::new(),
20081                    vec![NodeSessionPathBinding::new(
20082                        "CODEX_HOME",
20083                        if matches!(home_profile, CodexHomeProfile::Exact) {
20084                            NodeSessionPathClass::ProviderHome
20085                        } else {
20086                            NodeSessionPathClass::Config
20087                        },
20088                    )
20089                    .unwrap()],
20090                    Vec::new(),
20091                )
20092                .unwrap())
20093            }
20094        };
20095        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20096            profile_id.clone(),
20097            profile_revision.clone(),
20098            agent("codex"),
20099            [NativeLaunchProfile::new(
20100                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20101                agent("codex"),
20102                TransportKind::Pty,
20103                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20104                Arc::new(EmptyEnvironmentResolver),
20105            )
20106            .unwrap()],
20107            materialization,
20108        )
20109        .unwrap();
20110        let (binding, native_profiles, materialization) = node_profile.into_parts();
20111        for native_profile in native_profiles {
20112            runtime.upsert_native_launch_profile(native_profile).unwrap();
20113        }
20114        shared
20115            .environment_profiles
20116            .write()
20117            .unwrap_or_else(|poisoned| poisoned.into_inner())
20118            .insert(binding.id.clone(), binding);
20119        if let Some(materialization) = materialization {
20120            shared
20121                .environment_materialization_profiles
20122                .write()
20123                .unwrap_or_else(|poisoned| poisoned.into_inner())
20124                .insert(profile_id.clone(), materialization);
20125        }
20126
20127        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20128        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20129        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20130        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20131        std::fs::write(
20132            bundle_source.join(".claude-plugin/plugin.json"),
20133            CLAUDE_MANIFEST,
20134        )
20135        .unwrap();
20136        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20137        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20138        let bundle = NodeBundle::new(
20139            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20140            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20141            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20142            &bundle_source,
20143        )
20144        .unwrap();
20145        *shared
20146            .bundle_catalog
20147            .write()
20148            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20149            BundleCatalog::new([bundle.clone()]).unwrap();
20150        (
20151            shared,
20152            runtime,
20153            ResolvedEnvironmentProfileReceipt {
20154                profile_id,
20155                profile_revision,
20156                network_allowlist: None,
20157                browser_profile_id: None,
20158            },
20159            bundle,
20160            root,
20161        )
20162    }
20163
20164    #[cfg(feature = "fixture")]
20165    #[test]
20166    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20167        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20168            let (shared, runtime, receipt, bundle, root) =
20169                codex_bundle_test_shared(home_profile);
20170            let error = shared
20171                .bundle_layout(
20172                    &agent("codex"),
20173                    SessionMode::Pty,
20174                    Some(&receipt),
20175                    &bundle,
20176                )
20177                .unwrap_err();
20178            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20179            assert!(shared
20180                .materializations
20181                .lock()
20182                .unwrap_or_else(|poisoned| poisoned.into_inner())
20183                .is_empty());
20184            let materialization_root = root.join("session-environments");
20185            assert!(!std::fs::read_dir(&materialization_root)
20186                .unwrap()
20187                .filter_map(Result::ok)
20188                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20189            drop(shared);
20190            drop(runtime);
20191            let _ = std::fs::remove_dir_all(root);
20192        }
20193    }
20194
20195    #[cfg(feature = "fixture")]
20196    #[test]
20197    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20198        let (shared, runtime, receipt, bundle, root) =
20199            codex_bundle_test_shared(CodexHomeProfile::Exact);
20200        let address = SessionAddress {
20201            workspace_id: WorkspaceId::new("primary").unwrap(),
20202            session: SessionKey {
20203                instance_id: AgentInstanceId(310),
20204                generation: SessionGeneration(1),
20205            },
20206        };
20207        let (overlay, guard) = shared
20208            .prepare_session_materialization(
20209                &address,
20210                &agent("codex"),
20211                SessionMode::Pty,
20212                Some(&receipt),
20213                Some(&bundle.receipt()),
20214                None,
20215                None,
20216            )
20217            .unwrap()
20218            .unwrap();
20219        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20220        let ownership = shared
20221            .materializations
20222            .lock()
20223            .unwrap_or_else(|poisoned| poisoned.into_inner())
20224            .get(guard.id().unwrap())
20225            .unwrap()
20226            .clone();
20227        let (profile, _, _) = shared
20228            .materialization_profile(
20229                &agent("codex"),
20230                SessionMode::Pty,
20231                Some(&receipt),
20232                Some(&bundle.receipt()),
20233                None,
20234            )
20235            .unwrap()
20236            .unwrap();
20237        let environment = shared
20238            .session_environment_materializer
20239            .as_ref()
20240            .unwrap()
20241            .resolve_environment(&ownership, &profile)
20242            .unwrap();
20243        assert!(environment.iter().any(|mutation| {
20244            mutation.key == OsString::from("CODEX_HOME")
20245                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20246        }));
20247        assert_eq!(
20248            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20249                .unwrap(),
20250            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20251        );
20252        assert!(std::fs::read_dir(ownership.bundle_root())
20253            .unwrap()
20254            .next()
20255            .is_none());
20256        drop(guard);
20257        drop(shared);
20258        drop(runtime);
20259        let _ = std::fs::remove_dir_all(root);
20260    }
20261
20262    #[cfg(feature = "fixture")]
20263    #[test]
20264    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20265        let (shared, runtime, receipt, bundle, root) =
20266            codex_bundle_test_shared(CodexHomeProfile::Exact);
20267        let bundle_receipt = bundle.receipt();
20268        let address = SessionAddress {
20269            workspace_id: WorkspaceId::new("primary").unwrap(),
20270            session: SessionKey {
20271                instance_id: AgentInstanceId(311),
20272                generation: SessionGeneration(1),
20273            },
20274        };
20275        let (_overlay, guard) = shared
20276            .prepare_session_materialization(
20277                &address,
20278                &agent("codex"),
20279                SessionMode::Pty,
20280                Some(&receipt),
20281                Some(&bundle_receipt),
20282                None,
20283                None,
20284            )
20285            .unwrap()
20286            .unwrap();
20287        let materialization_id = guard.id().unwrap().clone();
20288        let skill_path = shared
20289            .materializations
20290            .lock()
20291            .unwrap_or_else(|poisoned| poisoned.into_inner())
20292            .get(&materialization_id)
20293            .unwrap()
20294            .provider_home()
20295            .join("skills/review-code/SKILL.md");
20296        let record_id = shared
20297            .bind_spawn_session_with_materialization(
20298                &address,
20299                agent("codex"),
20300                SessionMode::Pty,
20301                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20302                SpawnRecordPolicy::ProviderIdentityOnly,
20303                Some(receipt.clone()),
20304                Some(bundle_receipt.clone()),
20305                None,
20306                Some(materialization_id.clone()),
20307            )
20308            .unwrap()
20309            .unwrap();
20310        guard.retain();
20311        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20312
20313        let error = match shared.resolve_record_materialization(
20314                &record_id,
20315                &agent("codex"),
20316                SessionMode::Pty,
20317                Some(&receipt),
20318                Some(&bundle_receipt),
20319                None,
20320            ) {
20321                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20322                Err(error) => error,
20323            };
20324        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20325        assert_eq!(
20326            shared
20327                .materializations
20328                .lock()
20329                .unwrap_or_else(|poisoned| poisoned.into_inner())
20330                .get(&materialization_id)
20331                .unwrap()
20332                .state(),
20333            MaterializationState::RecoveryRequired,
20334        );
20335        drop(shared);
20336        drop(runtime);
20337        let _ = std::fs::remove_dir_all(root);
20338    }
20339
20340    fn bind_record_owned_materialization_fixture(
20341        shared: &NodeShared,
20342        receipt: &ResolvedEnvironmentProfileReceipt,
20343        instance_id: u64,
20344    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20345        let address = SessionAddress {
20346            workspace_id: WorkspaceId::new("primary").unwrap(),
20347            session: SessionKey {
20348                instance_id: AgentInstanceId(instance_id),
20349                generation: SessionGeneration(1),
20350            },
20351        };
20352        let (overlay, materialization_guard) = shared
20353            .prepare_session_materialization(
20354                &address,
20355                &agent("claude"),
20356                SessionMode::Pty,
20357                Some(receipt),
20358                None,
20359                None,
20360                None,
20361            )
20362            .unwrap()
20363            .unwrap();
20364        let materialization_id = materialization_guard.id().unwrap().clone();
20365        let materialization_root = shared
20366            .materializations
20367            .lock()
20368            .unwrap_or_else(|poisoned| poisoned.into_inner())
20369            .get(&materialization_id)
20370            .unwrap()
20371            .root()
20372            .to_path_buf();
20373        let selection = shared
20374            .select_environment_profile(
20375                address.session.instance_id,
20376                &agent("claude"),
20377                SessionMode::Pty,
20378                Some(receipt),
20379            )
20380            .unwrap()
20381            .unwrap();
20382        assert!(shared
20383            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20384            .unwrap()
20385            .is_none());
20386        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20387        let record_id = shared
20388            .bind_spawn_session_with_materialization(
20389                &address,
20390                agent("claude"),
20391                SessionMode::Pty,
20392                policy,
20393                SpawnRecordPolicy::ProviderIdentityOnly,
20394                Some(receipt.clone()),
20395                None,
20396                None,
20397                Some(materialization_id.clone()),
20398            )
20399            .unwrap()
20400            .unwrap();
20401        selection.retain();
20402        materialization_guard.retain();
20403        (address, record_id, materialization_id, materialization_root)
20404    }
20405
20406    #[test]
20407    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20408        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20409        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20410            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20411        }));
20412        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20413        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20414        let mut spec = spawn_spec_fixture();
20415        spec.expected_profile_revision =
20416            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20417        assert!(matches!(
20418            &spec.overrides.environment_profile_id,
20419            crate::protocol::SpawnOverride::Inherit,
20420        ));
20421        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20422            .requires_child_environment_profile_capability());
20423        assert!(request_requires_child_environment_profile(
20424            &shared,
20425            &NodeRequest::SpawnSpec { spec: spec.clone() },
20426        ));
20427
20428        let address = terminal_address(1);
20429        shared.bind_session_with_policy(
20430            &address,
20431            ProviderRuntimePolicy::raw_pty(),
20432            Some(environment_profile.clone()),
20433        );
20434        assert!(request_requires_child_environment_profile(
20435            &shared,
20436            &NodeRequest::Input {
20437                session: address.clone(),
20438                text: "x".to_owned(),
20439            },
20440        ));
20441
20442        let mut record = record("claude", "profile-record");
20443        record.active_session = Some(address.clone());
20444        record.environment_profile = Some(environment_profile.clone());
20445        let record_id = record.record_id.clone();
20446        shared.insert_record(record.clone()).unwrap();
20447        assert!(request_requires_child_environment_profile(
20448            &shared,
20449            &NodeRequest::ResumeSessionRecord {
20450                record_id,
20451                terminal_size: gate4agent_types::TerminalSize {
20452                    rows: 24,
20453                    columns: 80,
20454                },
20455                initial_prompt: None,
20456            },
20457        ));
20458
20459        let snapshot = snapshot_for_wire(
20460            &shared, false, true, true, true, false, false, false, false, true,
20461        );
20462        assert_eq!(snapshot.session_records.len(), 1);
20463        assert!(snapshot.session_records[0].environment_profile.is_none());
20464        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20465            sequence: 1,
20466            event: NodeEvent::SessionRecordUpserted { record },
20467        });
20468        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20469            panic!("record projection changed the event kind");
20470        };
20471        assert!(record.environment_profile.is_none());
20472
20473        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20474        let mut reply = ResponseEnvelope {
20475            request_id: 1,
20476            result: Ok(NodeResponse::SpawnSpecAccepted {
20477                receipt: resolved.receipt_with_environment(
20478                    shared.incarnation_id,
20479                    address,
20480                    Some(environment_profile),
20481                ),
20482            }),
20483        };
20484        project_response_without_child_environment_profile(&mut reply);
20485        assert_eq!(
20486            reply.result.unwrap_err().code,
20487            NodeFailureCode::UnsupportedCapability,
20488        );
20489    }
20490
20491    #[test]
20492    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20493        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20494        let snapshot = shared.snapshot();
20495        let profiles = snapshot
20496            .launch_inventory
20497            .as_ref()
20498            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20499            .unwrap();
20500        assert_eq!(profiles.len(), 1);
20501        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20502        assert!(snapshot.requires_child_environment_profile_capability());
20503
20504        let mut legacy = snapshot.clone();
20505        clear_snapshot_child_environment_profiles(&mut legacy);
20506        assert!(legacy
20507            .launch_inventory
20508            .as_ref()
20509            .unwrap()
20510            .spawn_profiles
20511            .as_ref()
20512            .unwrap()[0]
20513            .environment_profile
20514            .is_none());
20515        assert!(!legacy.requires_child_environment_profile_capability());
20516
20517        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20518        let bindings = shared
20519            .environment_profiles
20520            .read()
20521            .unwrap_or_else(|poisoned| poisoned.into_inner())
20522            .clone();
20523        let mut provider_mismatch = bindings.clone();
20524        provider_mismatch
20525            .values_mut()
20526            .next()
20527            .unwrap()
20528            .provider = agent("codex");
20529        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20530        let mut mode_mismatch = profile;
20531        mode_mismatch.mode = SessionMode::Inline;
20532        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20533
20534        shared
20535            .environment_profiles
20536            .write()
20537            .unwrap_or_else(|poisoned| poisoned.into_inner())
20538            .clear();
20539        assert!(shared
20540            .snapshot()
20541            .launch_inventory
20542            .unwrap()
20543            .spawn_profiles
20544            .unwrap()
20545            .is_empty());
20546    }
20547
20548    #[test]
20549    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20550        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20551            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20552        }));
20553        let shared = terminal_test_shared();
20554        let bundle = ResolvedBundleReceipt {
20555            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20556            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20557            digest: crate::protocol::SpawnBundleDigest::new(format!(
20558                "sha256:{}",
20559                "0".repeat(64),
20560            ))
20561            .unwrap(),
20562        };
20563        let mut bundled = record("claude", "bundle-record");
20564        bundled.bundle = Some(bundle.clone());
20565        let record_id = bundled.record_id.clone();
20566        shared.insert_record(bundled.clone()).unwrap();
20567        assert!(request_requires_session_bundle(
20568            &shared,
20569            &NodeRequest::ForgetSessionRecord {
20570                record_id: record_id.clone(),
20571            },
20572        ));
20573
20574        let snapshot = snapshot_for_wire(
20575            &shared, false, true, true, true, true, false, false, false, true,
20576        );
20577        assert!(snapshot.session_records[0].bundle.is_none());
20578        let launch = snapshot.launch_inventory.as_ref().unwrap();
20579        assert!(launch.spawn_profiles.is_some());
20580        assert!(launch.bundles.is_none());
20581        let event = project_event_without_session_bundle(NodeEventEnvelope {
20582            sequence: 1,
20583            event: NodeEvent::SessionRecordUpserted { record: bundled },
20584        });
20585        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20586            panic!("record projection changed the event kind");
20587        };
20588        assert!(record.bundle.is_none());
20589
20590        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20591        let mut reply = ResponseEnvelope {
20592            request_id: 1,
20593            result: Ok(NodeResponse::SpawnSpecAccepted {
20594                receipt: resolved.receipt_with_materialization(
20595                    shared.incarnation_id,
20596                    terminal_address(17),
20597                    None,
20598                    Some(bundle),
20599                    None,
20600                ),
20601            }),
20602        };
20603        project_response_without_session_bundle(&mut reply);
20604        assert_eq!(
20605            reply.result.unwrap_err().code,
20606            NodeFailureCode::UnsupportedCapability,
20607        );
20608    }
20609
20610    #[test]
20611    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20612        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20613            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20614        }));
20615        let context_id = SpawnContextId::new("context-review").unwrap();
20616        let context = ResolvedContextPackReceipt {
20617            id: context_id.clone(),
20618            digest: crate::protocol::SpawnContextDigest::new(format!(
20619                "sha256:{}",
20620                "c".repeat(64),
20621            ))
20622            .unwrap(),
20623            lineage: ContextPackLineageReceipt {
20624                source_node_id: NodeId::new("node-source").unwrap(),
20625                source_session: terminal_address(1),
20626                source_provider: agent("kimi"),
20627            },
20628            source_message_count: 2,
20629            retained_message_count: 2,
20630            byte_len: 256,
20631            truncated: false,
20632        };
20633        assert!(context.is_valid());
20634        let mut bound_record = record("codex", "context-record");
20635        bound_record.context_id = Some(context_id);
20636        bound_record.context = Some(context.clone());
20637        let session = gate4agent_types::SessionSnapshot {
20638            instance_id: AgentInstanceId(91),
20639            agent_id: agent("kimi"),
20640            transport: TransportKind::Pty,
20641            generation: SessionGeneration(3),
20642            status: SessionStatus::Running,
20643            pending_operation: None,
20644            pending_input: None,
20645            process_id: Some(9001),
20646            terminal_size: None,
20647            terminal_frame: None,
20648            terminal_stale: None,
20649            session_options: None,
20650            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20651            history: gate4agent_types::HistorySnapshot {
20652                pending: None,
20653                candidates: vec![HistoryCandidateSummary {
20654                    id: "candidate-opaque".to_owned(),
20655                    session_id_hint: "session-hint".to_owned(),
20656                    modified_at_unix_ms: Some(1),
20657                }],
20658                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20659                loaded: Some(HistorySessionRecord {
20660                    session_id: "private-vendor-session".to_owned(),
20661                    title: Some("private title".to_owned()),
20662                    cwd: Some(r"C:\private\history-root".to_owned()),
20663                    model: Some("private-model".to_owned()),
20664                    message_count: 2,
20665                    completed_turn_count: None,
20666                    total_tokens: 19,
20667                    messages: vec![
20668                        gate4agent_types::HistoryMessageRecord {
20669                            role: gate4agent_types::HistoryMessageRole::User,
20670                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20671                        },
20672                        gate4agent_types::HistoryMessageRecord {
20673                            role: gate4agent_types::HistoryMessageRole::Assistant,
20674                            text: "private answer".to_owned(),
20675                        },
20676                    ],
20677                }),
20678                last_error: None,
20679            },
20680            resume: gate4agent_types::ResumeSnapshot::default(),
20681            foreground: gate4agent_types::ForegroundSnapshot::default(),
20682            provider: gate4agent_types::ProviderSnapshot::default(),
20683            screen_state: gate4agent_types::PtyScreenState::default(),
20684        };
20685        let original = NodeSnapshot {
20686            node_id: NodeId::new("node-terminal-test").unwrap(),
20687            enabled_providers: vec![agent("kimi"), agent("codex")],
20688            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20689            workspaces: vec![WorkspaceSnapshot {
20690                workspace_id: WorkspaceId::new("primary").unwrap(),
20691                canonical_root: opaque_windows_path(
20692                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20693                ),
20694                sessions: vec![session],
20695                worktree_service_mode: None,
20696                managed_worktree_profiles: None,
20697            }],
20698            session_records: vec![bound_record],
20699            managed_worktrees: Vec::new(),
20700            launch_inventory: None,
20701            agent_progress: Vec::new(),
20702        };
20703
20704        let mut negotiated = original.clone();
20705        project_snapshot_history_for_wire(&mut negotiated, true);
20706        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20707        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20708        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20709        assert!(!serde_json::to_string(&negotiated)
20710            .unwrap()
20711            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20712
20713        let mut legacy = original;
20714        project_snapshot_history_for_wire(&mut legacy, false);
20715        clear_snapshot_context_packs(&mut legacy);
20716        assert_eq!(
20717            legacy.workspaces[0].sessions[0].history,
20718            gate4agent_types::HistorySnapshot::default(),
20719        );
20720        assert!(legacy.session_records[0].context_id.is_none());
20721        assert!(legacy.session_records[0].context.is_none());
20722        let encoded = serde_json::to_string(&legacy).unwrap();
20723        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20724        assert!(!encoded.contains("context-review"));
20725
20726        let mut reply = ResponseEnvelope {
20727            request_id: 7,
20728            result: Ok(NodeResponse::ContextPackExported { context }),
20729        };
20730        project_response_without_context_pack(&mut reply);
20731        assert_eq!(
20732            reply.result.unwrap_err().code,
20733            NodeFailureCode::UnsupportedCapability,
20734        );
20735    }
20736
20737    #[test]
20738    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20739        let shared = terminal_test_shared();
20740        let history = HistorySessionRecord {
20741            session_id: "fixture-history".to_owned(),
20742            title: None,
20743            cwd: None,
20744            model: None,
20745            message_count: 1,
20746            completed_turn_count: None,
20747            total_tokens: 0,
20748            messages: vec![gate4agent_types::HistoryMessageRecord {
20749                role: gate4agent_types::HistoryMessageRole::User,
20750                text: "bounded handoff".to_owned(),
20751            }],
20752        };
20753        let pack = NodeContextPack::export(
20754            ContextPackLineageReceipt {
20755                source_node_id: shared.node_id.clone(),
20756                source_session: terminal_address(1),
20757                source_provider: agent("claude"),
20758            },
20759            &history,
20760        )
20761        .unwrap();
20762        let context = shared
20763            .context_catalog
20764            .write()
20765            .unwrap_or_else(|poisoned| poisoned.into_inner())
20766            .insert(pack)
20767            .unwrap();
20768        let mut retained = record("codex", "context-retained-record");
20769        retained.context_id = Some(context.id.clone());
20770        retained.context = Some(context.clone());
20771        let record_id = retained.record_id.clone();
20772        shared.insert_record(retained).unwrap();
20773        assert_eq!(
20774            shared.forget_context_pack(&context.id).unwrap_err().code,
20775            NodeFailureCode::ContextPackBusy,
20776        );
20777        assert!(shared
20778            .context_catalog
20779            .read()
20780            .unwrap_or_else(|poisoned| poisoned.into_inner())
20781            .get(&context.id)
20782            .is_some());
20783        shared
20784            .session_records
20785            .lock()
20786            .unwrap_or_else(|poisoned| poisoned.into_inner())
20787            .records
20788            .remove(&record_id);
20789
20790        let mut spec = spawn_spec_fixture();
20791        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20792            value: context.id.clone(),
20793        };
20794        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20795        let receipt = resolved.receipt_with_materialization(
20796            shared.incarnation_id,
20797            terminal_address(2),
20798            None,
20799            None,
20800            Some(context.clone()),
20801        );
20802        assert!(receipt.context_binding_is_valid());
20803        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20804
20805        shared.forget_context_pack(&context.id).unwrap();
20806        assert!(shared
20807            .context_catalog
20808            .read()
20809            .unwrap_or_else(|poisoned| poisoned.into_inner())
20810            .get(&context.id)
20811            .is_none());
20812        assert!(shared
20813            .replay_spawn_spec(&spec, Instant::now())
20814            .unwrap()
20815            .is_none());
20816    }
20817
20818    #[test]
20819    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20820        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20821        let address = terminal_address(1);
20822        let selection = shared
20823            .select_environment_profile(
20824                address.session.instance_id,
20825                &agent("claude"),
20826                SessionMode::Pty,
20827                Some(&environment_profile),
20828            )
20829            .unwrap()
20830            .unwrap();
20831        shared.bind_session_with_policy(
20832            &address,
20833            ProviderRuntimePolicy::raw_pty(),
20834            Some(environment_profile.clone()),
20835        );
20836        selection.retain();
20837        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20838        assert!(matches!(
20839            runtime.remove_native_launch_profile(&native_id),
20840            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20841        ));
20842        assert!(shared.remove_binding(&address).is_some());
20843        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20844
20845        let mismatched = ResolvedEnvironmentProfileReceipt {
20846            profile_id: environment_profile.profile_id,
20847            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20848                "local-claude-r2",
20849            )
20850            .unwrap(),
20851            network_allowlist: None,
20852            browser_profile_id: None,
20853        };
20854        let error = match shared.select_environment_profile(
20855                AgentInstanceId(42),
20856                &agent("claude"),
20857                SessionMode::Pty,
20858                Some(&mismatched),
20859            ) {
20860            Ok(_) => panic!("mismatched environment profile revision was selected"),
20861            Err(error) => error,
20862        };
20863        assert_eq!(
20864            error.code,
20865            NodeFailureCode::EnvironmentProfileBindingMismatch,
20866        );
20867        assert!(!shared
20868            .session_bindings
20869            .lock()
20870            .unwrap_or_else(|poisoned| poisoned.into_inner())
20871            .contains_key(&AgentInstanceId(42)));
20872    }
20873
20874    #[test]
20875    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20876        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20877        let address = terminal_address(1);
20878        let selection = shared
20879            .select_environment_profile(
20880                address.session.instance_id,
20881                &agent("claude"),
20882                SessionMode::Pty,
20883                Some(&environment_profile),
20884            )
20885            .unwrap()
20886            .unwrap();
20887        let overlay = NativeInstanceLaunchOverlay::new(
20888            agent("claude"),
20889            TransportKind::Pty,
20890            vec![EnvMutation {
20891                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20892                value: Some(OsString::from("fixture-secret")),
20893            }],
20894            vec![
20895                OsString::from("--plugin-dir"),
20896                std::env::current_dir().unwrap().into_os_string(),
20897            ],
20898        )
20899        .unwrap();
20900        let instance_overlay = shared
20901            .install_prepared_launch_overlay(
20902                address.session.instance_id,
20903                PreparedNativeLaunchOverlay::Instance(overlay),
20904            )
20905            .unwrap()
20906            .unwrap();
20907        let bundle = ResolvedBundleReceipt {
20908            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20909            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20910            digest: crate::protocol::SpawnBundleDigest::new(format!(
20911                "sha256:{}",
20912                "0".repeat(64),
20913            ))
20914            .unwrap(),
20915        };
20916        shared.bind_session_with_materialization(
20917            &address,
20918            ProviderRuntimePolicy::raw_pty(),
20919            Some(environment_profile),
20920            Some(bundle),
20921            None,
20922            None,
20923        );
20924        selection.retain();
20925        instance_overlay.retain();
20926
20927        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20928        assert!(matches!(
20929            runtime.remove_native_launch_profile(&native_id),
20930            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20931        ));
20932        assert!(shared.remove_binding(&address).is_some());
20933        assert!(!shared
20934            .native_launch_profile_control
20935            .as_ref()
20936            .unwrap()
20937            .clear_native_instance_launch_overlay(address.session.instance_id));
20938        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20939    }
20940
20941    #[test]
20942    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20943        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20944        let address = SessionAddress {
20945            workspace_id: WorkspaceId::new("primary").unwrap(),
20946            session: SessionKey {
20947                instance_id: AgentInstanceId(201),
20948                generation: SessionGeneration(1),
20949            },
20950        };
20951        let error = match shared.prepare_session_materialization(
20952            &address,
20953            &agent("claude"),
20954            SessionMode::Pty,
20955            Some(&receipt),
20956            None,
20957            None,
20958            None,
20959        ) {
20960            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20961            Err(error) => error,
20962        };
20963        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20964        assert!(shared.handle.snapshot().sessions.is_empty());
20965        assert!(shared
20966            .session_bindings
20967            .lock()
20968            .unwrap_or_else(|poisoned| poisoned.into_inner())
20969            .is_empty());
20970        assert!(shared
20971            .materializations
20972            .lock()
20973            .unwrap_or_else(|poisoned| poisoned.into_inner())
20974            .is_empty());
20975        drop(shared);
20976        drop(runtime);
20977        std::fs::remove_dir_all(root).unwrap();
20978    }
20979
20980    #[test]
20981    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20982        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20983        let address = SessionAddress {
20984            workspace_id: WorkspaceId::new("primary").unwrap(),
20985            session: SessionKey {
20986                instance_id: AgentInstanceId(202),
20987                generation: SessionGeneration(1),
20988            },
20989        };
20990        let (overlay, materialization_guard) = shared
20991            .prepare_session_materialization(
20992                &address,
20993                &agent("claude"),
20994                SessionMode::Pty,
20995                Some(&receipt),
20996                None,
20997                None,
20998                None,
20999            )
21000            .unwrap()
21001            .unwrap();
21002        let materialization_id = materialization_guard.id().unwrap().clone();
21003        let materialization_root = shared
21004            .materializations
21005            .lock()
21006            .unwrap_or_else(|poisoned| poisoned.into_inner())
21007            .get(&materialization_id)
21008            .unwrap()
21009            .root()
21010            .to_path_buf();
21011        let selection = shared
21012            .select_environment_profile(
21013                address.session.instance_id,
21014                &agent("claude"),
21015                SessionMode::Pty,
21016                Some(&receipt),
21017            )
21018            .unwrap()
21019            .unwrap();
21020        assert!(shared
21021            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21022            .unwrap()
21023            .is_none());
21024        shared.bind_session_with_materialization(
21025            &address,
21026            ProviderRuntimePolicy::raw_pty(),
21027            Some(receipt),
21028            None,
21029            None,
21030            Some(materialization_id),
21031        );
21032        selection.retain();
21033        materialization_guard.retain();
21034
21035        assert!(materialization_root.is_dir());
21036        assert!(shared
21037            .session_bindings
21038            .lock()
21039            .unwrap_or_else(|poisoned| poisoned.into_inner())
21040            .contains_key(&address.session.instance_id));
21041
21042        let removed = shared.remove_binding(&address).unwrap();
21043        shared
21044            .cleanup_session_owned_materialization(&address, &removed)
21045            .unwrap();
21046        assert!(!materialization_root.exists());
21047        assert!(shared
21048            .materializations
21049            .lock()
21050            .unwrap_or_else(|poisoned| poisoned.into_inner())
21051            .is_empty());
21052        drop(shared);
21053        drop(runtime);
21054        std::fs::remove_dir_all(root).unwrap();
21055    }
21056
21057    #[tokio::test]
21058    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21059        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21060        let address = SessionAddress {
21061            workspace_id: WorkspaceId::new("primary").unwrap(),
21062            session: SessionKey {
21063                instance_id: AgentInstanceId(203),
21064                generation: SessionGeneration(1),
21065            },
21066        };
21067        let (overlay, materialization_guard) = shared
21068            .prepare_session_materialization(
21069                &address,
21070                &agent("claude"),
21071                SessionMode::Pty,
21072                Some(&receipt),
21073                None,
21074                None,
21075                None,
21076            )
21077            .unwrap()
21078            .unwrap();
21079        let materialization_id = materialization_guard.id().unwrap().clone();
21080        let materialization_root = shared
21081            .materializations
21082            .lock()
21083            .unwrap_or_else(|poisoned| poisoned.into_inner())
21084            .get(&materialization_id)
21085            .unwrap()
21086            .root()
21087            .to_path_buf();
21088        let selection = shared
21089            .select_environment_profile(
21090                address.session.instance_id,
21091                &agent("claude"),
21092                SessionMode::Pty,
21093                Some(&receipt),
21094            )
21095            .unwrap()
21096            .unwrap();
21097        assert!(shared
21098            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21099            .unwrap()
21100            .is_none());
21101        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21102        let record_id = shared
21103            .bind_spawn_session_with_materialization(
21104                &address,
21105                agent("claude"),
21106                SessionMode::Pty,
21107                policy,
21108                SpawnRecordPolicy::ProviderIdentityOnly,
21109                Some(receipt.clone()),
21110                None,
21111                None,
21112                Some(materialization_id.clone()),
21113            )
21114            .unwrap()
21115            .unwrap();
21116        selection.retain();
21117        materialization_guard.retain();
21118        assert_eq!(
21119            shared
21120                .materializations
21121                .lock()
21122                .unwrap_or_else(|poisoned| poisoned.into_inner())
21123                .get(&materialization_id)
21124                .unwrap()
21125                .owner(),
21126            &MaterializationOwner::Record {
21127                record_id: record_id.clone(),
21128            },
21129        );
21130
21131        let removed = shared.remove_binding(&address).unwrap();
21132        shared
21133            .cleanup_session_owned_materialization(&address, &removed)
21134            .unwrap();
21135        assert!(materialization_root.is_dir());
21136        assert!(shared
21137            .resolve_record_materialization(
21138                &record_id,
21139                &agent("claude"),
21140                SessionMode::Pty,
21141                Some(&receipt),
21142                None,
21143                None,
21144            )
21145            .unwrap()
21146            .is_some());
21147        {
21148            let mut records = shared
21149                .session_records
21150                .lock()
21151                .unwrap_or_else(|poisoned| poisoned.into_inner());
21152            let record = records.records.get_mut(&record_id).unwrap();
21153            record.active_session = None;
21154            record.state = ManagedSessionState::Unavailable;
21155        }
21156        shared.forget_session_record(&record_id).await.unwrap();
21157        assert!(!materialization_root.exists());
21158        assert!(shared
21159            .materializations
21160            .lock()
21161            .unwrap_or_else(|poisoned| poisoned.into_inner())
21162            .is_empty());
21163        drop(shared);
21164        drop(runtime);
21165        std::fs::remove_dir_all(root).unwrap();
21166    }
21167
21168    #[test]
21169    fn record_materialization_revalidation_failure_persists_recovery_required() {
21170        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21171        let state_path = root.join("node-state-v6.json");
21172        shared.state_path = Some(state_path.clone());
21173        let (_address, _record_id, materialization_id, materialization_root) =
21174            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21175        std::fs::write(
21176            materialization_root.join(".gate4agent-materialization-owner"),
21177            b"tampered",
21178        )
21179        .unwrap();
21180
21181        let error = match shared.resolve_record_materialization(
21182            &_record_id,
21183            &agent("claude"),
21184            SessionMode::Pty,
21185            Some(&receipt),
21186            None,
21187            None,
21188        ) {
21189            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21190            Err(error) => error,
21191        };
21192        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21193        assert_eq!(
21194            shared
21195                .materializations
21196                .lock()
21197                .unwrap_or_else(|poisoned| poisoned.into_inner())
21198                .get(&materialization_id)
21199                .unwrap()
21200                .state(),
21201            MaterializationState::RecoveryRequired,
21202        );
21203        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21204        assert_eq!(loaded.materializations.len(), 1);
21205        assert_eq!(
21206            loaded.materializations[0].state(),
21207            MaterializationState::RecoveryRequired,
21208        );
21209        drop(shared);
21210        drop(runtime);
21211        std::fs::remove_dir_all(root).unwrap();
21212    }
21213
21214    #[test]
21215    fn transient_record_materialization_resolution_failure_remains_ready() {
21216        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21217        let (_address, record_id, materialization_id, _materialization_root) =
21218            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21219        deny.store(true, Ordering::Release);
21220
21221        let error = match shared.resolve_record_materialization(
21222            &record_id,
21223            &agent("claude"),
21224            SessionMode::Pty,
21225            Some(&receipt),
21226            None,
21227            None,
21228        ) {
21229            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21230            Err(error) => error,
21231        };
21232        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21233        assert_eq!(
21234            shared
21235                .materializations
21236                .lock()
21237                .unwrap_or_else(|poisoned| poisoned.into_inner())
21238                .get(&materialization_id)
21239                .unwrap()
21240                .state(),
21241            MaterializationState::Ready,
21242        );
21243        drop(shared);
21244        drop(runtime);
21245        std::fs::remove_dir_all(root).unwrap();
21246    }
21247
21248    #[test]
21249    fn startup_missing_record_materialization_is_not_resumable() {
21250        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21251        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21252        shared
21253            .insert_record(ManagedSessionRecord {
21254                record_id: record_id.clone(),
21255                display_name: "missing materialization".to_owned(),
21256                provider: agent("claude"),
21257                mode: SessionMode::Pty,
21258                state: ManagedSessionState::Dormant,
21259                workspace_id: WorkspaceId::new("primary").unwrap(),
21260                canonical_root: opaque_windows_path(
21261                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21262                ),
21263                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21264                    key: gate4agent_types::ProviderSessionKey::SessionId,
21265                    id: "provider-session-missing-materialization".to_owned(),
21266                    transcript_path: None,
21267                }),
21268                active_session: None,
21269                environment_profile: Some(receipt),
21270                bundle: None,
21271                context_id: None,
21272                context: None,
21273                exported_context: None,
21274                task_binding: None,
21275                created_at_unix_ms: 1,
21276                updated_at_unix_ms: 1,
21277                last_error: None,
21278            })
21279            .unwrap();
21280
21281        shared.reconcile_materializations();
21282        let record = shared.record(&record_id).unwrap();
21283        assert_eq!(record.state, ManagedSessionState::Unavailable);
21284        assert!(record.provider_session.is_none());
21285        assert_eq!(
21286            record.last_error.as_deref(),
21287            Some("environment-profile-unavailable"),
21288        );
21289        drop(shared);
21290        drop(runtime);
21291        std::fs::remove_dir_all(root).unwrap();
21292    }
21293
21294    #[tokio::test]
21295    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21296        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21297        let lease = install_managed_lease(&shared);
21298        let address = SessionAddress {
21299            workspace_id: WorkspaceId::new("primary").unwrap(),
21300            session: SessionKey {
21301                instance_id: AgentInstanceId(204),
21302                generation: SessionGeneration(1),
21303            },
21304        };
21305        let (overlay, materialization_guard) = shared
21306            .prepare_session_materialization(
21307                &address,
21308                &agent("claude"),
21309                SessionMode::Pty,
21310                Some(&receipt),
21311                None,
21312                None,
21313                Some(lease.lease_id.clone()),
21314            )
21315            .unwrap()
21316            .unwrap();
21317        let materialization_id = materialization_guard.id().unwrap().clone();
21318        let materialization_root = shared
21319            .materializations
21320            .lock()
21321            .unwrap_or_else(|poisoned| poisoned.into_inner())
21322            .get(&materialization_id)
21323            .unwrap()
21324            .root()
21325            .to_path_buf();
21326        let selection = shared
21327            .select_environment_profile(
21328                address.session.instance_id,
21329                &agent("claude"),
21330                SessionMode::Pty,
21331                Some(&receipt),
21332            )
21333            .unwrap()
21334            .unwrap();
21335        assert!(shared
21336            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21337            .unwrap()
21338            .is_none());
21339        shared.bind_session_with_materialization(
21340            &address,
21341            ProviderRuntimePolicy::raw_pty(),
21342            Some(receipt),
21343            None,
21344            None,
21345            Some(materialization_id),
21346        );
21347        selection.retain();
21348        materialization_guard.retain();
21349
21350        let blocked = shared
21351            .cleanup_managed_worktree(&lease.lease_id, false)
21352            .await
21353            .unwrap_err();
21354        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21355        assert!(materialization_root.is_dir());
21356        let removed = shared.remove_binding(&address).unwrap();
21357        shared
21358            .cleanup_session_owned_materialization(&address, &removed)
21359            .unwrap();
21360        assert!(!materialization_root.exists());
21361        drop(shared);
21362        drop(runtime);
21363        std::fs::remove_dir_all(root).unwrap();
21364    }
21365
21366    #[tokio::test]
21367    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21368        let git_root = temporary_workspace_root("raw-managed-inventory");
21369        let source = git_root.join("source");
21370        let allocation = git_root.join("allocation");
21371        std::fs::create_dir_all(&source).unwrap();
21372        std::fs::create_dir_all(&allocation).unwrap();
21373        run_test_git(&source, &["init"]);
21374        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21375        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21376        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21377        run_test_git(&source, &["add", "seed.txt"]);
21378        run_test_git(&source, &["commit", "-m", "seed"]);
21379        let profile = ManagedWorktreeProfile::new(
21380            WorktreeProfileId::new("default").unwrap(),
21381            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21382            &allocation,
21383            "gate4agent",
21384            "HEAD",
21385            ManagedWorktreeRetention::RemoveWhenReleased,
21386        )
21387        .unwrap();
21388        let workspace = WorkspaceConfig::new(
21389            WorkspaceId::new("primary").unwrap(),
21390            &source,
21391        )
21392        .unwrap()
21393        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21394        .with_managed_worktree_profile(profile.clone())
21395        .unwrap();
21396        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21397            materialization_test_shared_with_workspace(false, workspace);
21398        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21399        let source_text = source.to_string_lossy().into_owned();
21400        let target_text = target.to_string_lossy().into_owned();
21401        let created = create_git_worktree(
21402            &source_text,
21403            &target_text,
21404            "gate4agent/mw-raw-inventory",
21405            Some("HEAD"),
21406        )
21407        .await
21408        .unwrap();
21409        let lease = ManagedWorktreeLeaseRecord {
21410            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21411            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21412            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21413            profile_id: profile.profile_id().clone(),
21414            profile_revision: profile.revision().clone(),
21415            target_root: created.path.clone(),
21416            branch: "gate4agent/mw-raw-inventory".to_owned(),
21417            base_commit: created.head.clone(),
21418            expected_head: Some(created.head.clone()),
21419            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21420            state: ManagedWorktreeLeaseState::Ready,
21421            session_holders: Vec::new(),
21422            record_holders: Vec::new(),
21423            cleanup_failure: None,
21424            created_at_unix_ms: 1,
21425            updated_at_unix_ms: 1,
21426        };
21427        shared
21428            .workspaces
21429            .write()
21430            .unwrap_or_else(|poisoned| poisoned.into_inner())
21431            .insert(lease.workspace_id.clone(), created.path.clone());
21432        *shared
21433            .managed_worktrees
21434            .lock()
21435            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21436            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21437        let address = SessionAddress {
21438            workspace_id: lease.workspace_id.clone(),
21439            session: SessionKey {
21440                instance_id: AgentInstanceId(205),
21441                generation: SessionGeneration(1),
21442            },
21443        };
21444        let (overlay, materialization_guard) = shared
21445            .prepare_session_materialization(
21446                &address,
21447                &agent("claude"),
21448                SessionMode::Pty,
21449                Some(&receipt),
21450                None,
21451                None,
21452                Some(lease.lease_id.clone()),
21453            )
21454            .unwrap()
21455            .unwrap();
21456        let materialization_id = materialization_guard.id().unwrap().clone();
21457        let materialization_root = shared
21458            .materializations
21459            .lock()
21460            .unwrap_or_else(|poisoned| poisoned.into_inner())
21461            .get(&materialization_id)
21462            .unwrap()
21463            .root()
21464            .to_path_buf();
21465        let selection = shared
21466            .select_environment_profile(
21467                address.session.instance_id,
21468                &agent("claude"),
21469                SessionMode::Pty,
21470                Some(&receipt),
21471            )
21472            .unwrap()
21473            .unwrap();
21474        assert!(shared
21475            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21476            .unwrap()
21477            .is_none());
21478        let record_id = shared
21479            .bind_spawn_session_with_materialization(
21480                &address,
21481                agent("claude"),
21482                SessionMode::Pty,
21483                ProviderRuntimePolicy::raw_pty(),
21484                SpawnRecordPolicy::Always,
21485                Some(receipt.clone()),
21486                None,
21487                None,
21488                Some(materialization_id.clone()),
21489            )
21490            .unwrap()
21491            .unwrap();
21492        selection.retain();
21493        materialization_guard.retain();
21494        let record = shared.record(&record_id).unwrap();
21495        assert_eq!(record.state, ManagedSessionState::Live);
21496        assert!(record.provider_session.is_none());
21497        assert!(record.environment_profile.is_none());
21498        assert!(record.bundle.is_none());
21499        assert!(record.context_id.is_none());
21500        assert!(record.context.is_none());
21501        assert_eq!(record.active_session.as_ref(), Some(&address));
21502        assert_eq!(
21503            shared
21504                .materializations
21505                .lock()
21506                .unwrap_or_else(|poisoned| poisoned.into_inner())
21507                .get(&materialization_id)
21508                .unwrap()
21509                .owner(),
21510            &MaterializationOwner::Session {
21511                incarnation_id: shared.incarnation_id,
21512                instance_id: address.session.instance_id,
21513                generation: address.session.generation,
21514            },
21515        );
21516        {
21517            let mut bindings = shared
21518                .session_bindings
21519                .lock()
21520                .unwrap_or_else(|poisoned| poisoned.into_inner());
21521            bindings
21522                .get_mut(&address.session.instance_id)
21523                .unwrap()
21524                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21525        }
21526        let lease_snapshot = shared
21527            .managed_worktrees
21528            .lock()
21529            .unwrap_or_else(|poisoned| poisoned.into_inner())
21530            .bind_session(
21531                &lease.lease_id,
21532                ManagedWorktreeSessionHolder {
21533                    incarnation_id: shared.incarnation_id,
21534                    instance_id: address.session.instance_id,
21535                    generation: address.session.generation,
21536                },
21537                None,
21538                unix_time_ms(),
21539            )
21540            .unwrap();
21541        assert_eq!(lease_snapshot.active_session_count, 1);
21542        assert_eq!(lease_snapshot.managed_record_count, 0);
21543        shared.persist_state().unwrap();
21544        assert!(shared
21545            .managed_worktrees
21546            .lock()
21547            .unwrap_or_else(|poisoned| poisoned.into_inner())
21548            .get(&lease.lease_id)
21549            .unwrap()
21550            .record_holders
21551            .is_empty());
21552        shared
21553            .handle
21554            .dispatch(shared.prepare_command(ControlCommand::Register {
21555                instance_id: address.session.instance_id,
21556                agent_id: agent("claude"),
21557                transport: TransportKind::Pty,
21558            }))
21559            .unwrap();
21560        runtime.tick().await;
21561        shared.reconcile_managed_record(
21562            &record_id,
21563            &address,
21564            &ControlEvent {
21565                sequence: 1,
21566                command_id: None,
21567                instance_id: address.session.instance_id,
21568                generation: address.session.generation,
21569                event: ControlEventKind::Removed,
21570            },
21571            false,
21572            false,
21573        );
21574        let remove = shared.remove_session(&address);
21575        let drive_runtime = async {
21576            runtime.tick().await;
21577        };
21578        let (removed, ()) = tokio::join!(remove, drive_runtime);
21579        removed.unwrap();
21580
21581        assert!(!materialization_root.exists());
21582        assert!(!Path::new(&created.path).exists());
21583        assert_eq!(
21584            shared
21585                .managed_worktrees
21586                .lock()
21587                .unwrap_or_else(|poisoned| poisoned.into_inner())
21588                .get(&lease.lease_id)
21589                .unwrap()
21590                .state,
21591            ManagedWorktreeLeaseState::Removed,
21592        );
21593        let renamed = shared
21594            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21595            .unwrap();
21596        assert_eq!(renamed.record_id, record_id);
21597        assert_eq!(renamed.display_name, "raw managed inventory");
21598        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21599        assert!(renamed.active_session.is_none());
21600
21601        drop(shared);
21602        drop(runtime);
21603        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21604        std::fs::remove_dir_all(git_root).unwrap();
21605    }
21606
21607    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21608        let root = std::env::current_dir().unwrap()
21609            .join("managed-test-target")
21610            .to_string_lossy()
21611            .into_owned();
21612        let lease = ManagedWorktreeLeaseRecord {
21613            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21614            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21615            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21616            profile_id: WorktreeProfileId::new("default").unwrap(),
21617            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21618            target_root: root.clone(),
21619            branch: "gate4agent/mw-test".to_owned(),
21620            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21621            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21622            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21623            state: ManagedWorktreeLeaseState::Ready,
21624            session_holders: Vec::new(),
21625            record_holders: Vec::new(),
21626            cleanup_failure: None,
21627            created_at_unix_ms: 1,
21628            updated_at_unix_ms: 1,
21629        };
21630        shared.workspaces.write()
21631            .unwrap_or_else(|poisoned| poisoned.into_inner())
21632            .insert(lease.workspace_id.clone(), root);
21633        *shared.managed_worktrees.lock()
21634            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21635            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21636        lease
21637    }
21638
21639    #[cfg(feature = "fixture")]
21640    #[test]
21641    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21642        let mut shared = terminal_test_shared();
21643        shared.fixture_semantic_hook_policy = true;
21644        let lease = install_managed_lease(&shared);
21645        let address = SessionAddress {
21646            workspace_id: lease.workspace_id.clone(),
21647            session: SessionKey {
21648                instance_id: AgentInstanceId(206),
21649                generation: SessionGeneration(1),
21650            },
21651        };
21652        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21653        let effective_runtime_policy =
21654            shared.effective_spawn_runtime_policy(pre_admission_policy);
21655        assert_ne!(effective_runtime_policy, pre_admission_policy);
21656        assert!(effective_runtime_policy.provider_session_identity);
21657
21658        let record_id = shared
21659            .bind_spawn_session(
21660                &address,
21661                agent("claude"),
21662                SessionMode::Pty,
21663                effective_runtime_policy,
21664                SpawnRecordPolicy::Always,
21665                None,
21666            )
21667            .unwrap()
21668            .unwrap();
21669        assert_eq!(
21670            shared.record(&record_id).unwrap().state,
21671            ManagedSessionState::IdentityPending,
21672        );
21673        let (record_holder, raw_inventory_record_id) = {
21674            let mut bindings = shared
21675                .session_bindings
21676                .lock()
21677                .unwrap_or_else(|poisoned| poisoned.into_inner());
21678            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21679            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21680            managed_spawn_record_ownership(binding, effective_runtime_policy)
21681        };
21682        assert_eq!(record_holder.as_ref(), Some(&record_id));
21683        assert!(raw_inventory_record_id.is_none());
21684        let snapshot = shared
21685            .managed_worktrees
21686            .lock()
21687            .unwrap_or_else(|poisoned| poisoned.into_inner())
21688            .bind_session(
21689                &lease.lease_id,
21690                ManagedWorktreeSessionHolder {
21691                    incarnation_id: shared.incarnation_id,
21692                    instance_id: address.session.instance_id,
21693                    generation: address.session.generation,
21694                },
21695                record_holder,
21696                unix_time_ms(),
21697            )
21698            .unwrap();
21699        assert_eq!(snapshot.managed_record_count, 1);
21700        assert_eq!(
21701            shared
21702                .managed_worktrees
21703                .lock()
21704                .unwrap_or_else(|poisoned| poisoned.into_inner())
21705                .get(&lease.lease_id)
21706                .unwrap()
21707                .record_holders,
21708            vec![record_id.clone()],
21709        );
21710
21711        let identity = ProviderSessionIdentity {
21712            key: ProviderSessionKey::SessionId,
21713            id: "monitoring-fixture-managed-session".to_owned(),
21714            transcript_path: None,
21715        };
21716        shared.publish_control(ControlEvent {
21717            sequence: 1,
21718            command_id: None,
21719            instance_id: address.session.instance_id,
21720            generation: address.session.generation,
21721            event: ControlEventKind::ProviderEvent {
21722                sequence: 1,
21723                source: gate4agent_types::ProviderSource {
21724                    family: AdapterFamily::Hook,
21725                    binding: AdapterBinding::new(
21726                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21727                        "fixture/v1",
21728                        gate4agent_types::AdapterVerification::SyntheticFixture,
21729                    )
21730                    .unwrap(),
21731                },
21732                source_sequence: 1,
21733                event: ProviderEvent::SessionIdentityObserved {
21734                    identity: identity.clone(),
21735                },
21736            },
21737        });
21738
21739        let live = shared.record(&record_id).unwrap();
21740        assert_eq!(live.state, ManagedSessionState::Live);
21741        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21742        assert_eq!(live.active_session.as_ref(), Some(&address));
21743        assert_eq!(
21744            shared
21745                .managed_worktrees
21746                .lock()
21747                .unwrap_or_else(|poisoned| poisoned.into_inner())
21748                .get(&lease.lease_id)
21749                .unwrap()
21750                .record_holders,
21751            vec![record_id],
21752        );
21753    }
21754
21755    fn managed_v2_test_shared(
21756        allocation_root: &Path,
21757        state_path: Option<PathBuf>,
21758        profile_revision: &str,
21759    ) -> NodeShared {
21760        let profile = ManagedWorktreeProfile::new(
21761            WorktreeProfileId::new("default").unwrap(),
21762            WorktreeProfileRevision::new(profile_revision).unwrap(),
21763            allocation_root,
21764            "gate4agent",
21765            "HEAD",
21766            ManagedWorktreeRetention::RemoveWhenReleased,
21767        )
21768        .unwrap();
21769        let workspace = WorkspaceConfig::new(
21770            WorkspaceId::new("primary").unwrap(),
21771            std::env::current_dir().unwrap(),
21772        )
21773        .unwrap()
21774        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21775        .with_managed_worktree_profile(profile)
21776        .unwrap();
21777        let catalog = active_registry().unwrap();
21778        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21779        let mut shared = NodeShared::new(
21780            handle,
21781            "fixture-token".to_owned(),
21782            NodeId::new("node-terminal-test").unwrap(),
21783            vec![workspace],
21784            vec![agent("claude")],
21785        );
21786        shared.state_path = state_path;
21787        shared
21788    }
21789
21790    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21791        let mut spawn_spec = spawn_spec_fixture();
21792        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21793        ManagedWorktreeSpawnRequestV2 {
21794            spawn_spec,
21795            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21796            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21797        }
21798    }
21799
21800    #[test]
21801    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21802        let shared = terminal_test_shared();
21803        let mut lease = install_managed_lease(&shared);
21804        let session = SessionAddress {
21805            workspace_id: lease.workspace_id.clone(),
21806            session: SessionKey {
21807                instance_id: AgentInstanceId(73),
21808                generation: SessionGeneration(4),
21809            },
21810        };
21811        lease.state = ManagedWorktreeLeaseState::InUse;
21812        lease.session_holders.push(ManagedWorktreeSessionHolder {
21813            incarnation_id: shared.incarnation_id,
21814            instance_id: session.session.instance_id,
21815            generation: session.session.generation,
21816        });
21817        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21818        assert!(resolved.target.worktree_id.is_none());
21819
21820        let receipt = managed_spawn_receipt(
21821            &resolved,
21822            shared.incarnation_id,
21823            session.clone(),
21824            lease.snapshot(),
21825            None,
21826            None,
21827            None,
21828        );
21829        assert!(resolved.target.worktree_id.is_none());
21830        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21831        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21832        assert_eq!(receipt.spawn.session, session);
21833        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21834        let encoded = serde_json::to_vec(&receipt).unwrap();
21835        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21836        assert_eq!(decoded, receipt);
21837    }
21838
21839    #[tokio::test]
21840    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21841        let root = temporary_workspace_root("managed-v2-revision");
21842        let allocation = root.join("allocation");
21843        std::fs::create_dir_all(&allocation).unwrap();
21844        let shared = managed_v2_test_shared(
21845            &allocation,
21846            Some(root.join("node-state.json")),
21847            "v1",
21848        );
21849        let mut request = managed_v2_request("managed-v2-revision");
21850        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21851
21852        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21853
21854        assert_eq!(
21855            failure.code,
21856            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21857        );
21858        assert!(shared
21859            .managed_worktrees
21860            .lock()
21861            .unwrap_or_else(|poisoned| poisoned.into_inner())
21862            .records()
21863            .is_empty());
21864        assert!(shared
21865            .managed_spawn_replays
21866            .lock()
21867            .unwrap_or_else(|poisoned| poisoned.into_inner())
21868            .is_empty());
21869        std::fs::remove_dir_all(root).unwrap();
21870    }
21871
21872    #[test]
21873    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21874        let root = temporary_workspace_root("managed-v2-concurrent");
21875        let allocation = root.join("allocation");
21876        std::fs::create_dir_all(&allocation).unwrap();
21877        let shared = Arc::new(managed_v2_test_shared(
21878            &allocation,
21879            Some(root.join("node-state.json")),
21880            "v1",
21881        ));
21882        let request = managed_v2_request("managed-v2-concurrent");
21883        let barrier = Arc::new(std::sync::Barrier::new(3));
21884        let mut joins = Vec::new();
21885        for _ in 0..2 {
21886            let shared = Arc::clone(&shared);
21887            let request = request.clone();
21888            let barrier = Arc::clone(&barrier);
21889            joins.push(std::thread::spawn(move || {
21890                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21891                barrier.wait();
21892                shared.acquire_durable_managed_spawn_v2(&request, digest)
21893            }));
21894        }
21895        barrier.wait();
21896        let results = joins
21897            .into_iter()
21898            .map(|join| join.join().unwrap())
21899            .collect::<Vec<_>>();
21900        assert_eq!(
21901            results
21902                .iter()
21903                .filter(|result| {
21904                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21905                })
21906                .count(),
21907            1,
21908        );
21909        assert_eq!(
21910            results
21911                .iter()
21912                .filter(|result| {
21913                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21914                })
21915                .count(),
21916            1,
21917        );
21918        assert_eq!(
21919            shared
21920                .managed_spawn_replays
21921                .lock()
21922                .unwrap_or_else(|poisoned| poisoned.into_inner())
21923                .len(),
21924            1,
21925        );
21926        assert!(shared
21927            .managed_worktrees
21928            .lock()
21929            .unwrap_or_else(|poisoned| poisoned.into_inner())
21930            .records()
21931            .is_empty());
21932        drop(shared);
21933        std::fs::remove_dir_all(root).unwrap();
21934    }
21935
21936    #[tokio::test]
21937    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21938        let root = temporary_workspace_root("managed-v2-linked-pending");
21939        let allocation = root.join("allocation");
21940        std::fs::create_dir_all(&allocation).unwrap();
21941        let state_path = root.join("node-state.json");
21942        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21943        let lease = install_managed_lease(&shared);
21944        let request = managed_v2_request("managed-v2-linked-pending");
21945        assert!(matches!(
21946            shared
21947                .acquire_durable_managed_spawn_v2(
21948                    &request,
21949                    managed_spawn_request_digest_v2(&request).unwrap(),
21950                )
21951                .unwrap(),
21952            DurableManagedSpawnReplayDecision::Reserved,
21953        ));
21954        {
21955            let _transaction = shared
21956                .state_transaction
21957                .lock()
21958                .unwrap_or_else(|poisoned| poisoned.into_inner());
21959            shared
21960                .link_durable_managed_spawn_v2_lease_locked(
21961                    &request.spawn_spec.idempotency_key,
21962                    &lease.lease_id,
21963                )
21964                .unwrap();
21965            shared.persist_state_locked().unwrap();
21966        }
21967        drop(shared);
21968
21969        let mut loaded = session_registry::load(
21970            Some(&state_path),
21971            &NodeId::new("node-terminal-test").unwrap(),
21972        )
21973        .unwrap();
21974        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21975        *reopened
21976            .managed_worktrees
21977            .lock()
21978            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21979            ManagedWorktreeRegistry::from_records(
21980                std::mem::take(&mut loaded.managed_worktrees),
21981                std::mem::take(&mut loaded.managed_worktree_tombstones),
21982            )
21983            .unwrap();
21984        *reopened
21985            .managed_spawn_replays
21986            .lock()
21987            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21988            .managed_spawn_replays
21989            .into_iter()
21990            .map(|record| (record.idempotency_key.clone(), record))
21991            .collect();
21992        let lease_count = reopened
21993            .managed_worktrees
21994            .lock()
21995            .unwrap_or_else(|poisoned| poisoned.into_inner())
21996            .records()
21997            .len();
21998
21999        let failure = reopened
22000            .spawn_managed_worktree_v2(request)
22001            .await
22002            .unwrap_err();
22003
22004        assert_eq!(
22005            failure.code,
22006            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22007        );
22008        assert_eq!(
22009            reopened
22010                .managed_worktrees
22011                .lock()
22012                .unwrap_or_else(|poisoned| poisoned.into_inner())
22013                .records()
22014                .len(),
22015            lease_count,
22016        );
22017        std::fs::remove_dir_all(root).unwrap();
22018    }
22019
22020    #[tokio::test]
22021    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22022        let root = temporary_workspace_root("managed-v2-reopen");
22023        let allocation = root.join("allocation");
22024        std::fs::create_dir_all(&allocation).unwrap();
22025        let state_path = root.join("node-state.json");
22026        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22027        let lease = install_managed_lease(&shared);
22028        let request = managed_v2_request("managed-v2-reopen");
22029        let session = SessionAddress {
22030            workspace_id: lease.workspace_id.clone(),
22031            session: SessionKey {
22032                instance_id: AgentInstanceId(91),
22033                generation: SessionGeneration(1),
22034            },
22035        };
22036        let snapshot = {
22037            let mut registry = shared
22038                .managed_worktrees
22039                .lock()
22040                .unwrap_or_else(|poisoned| poisoned.into_inner());
22041            let current = registry.get_mut(&lease.lease_id).unwrap();
22042            current.state = ManagedWorktreeLeaseState::InUse;
22043            current.session_holders.push(ManagedWorktreeSessionHolder {
22044                incarnation_id: shared.incarnation_id,
22045                instance_id: session.session.instance_id,
22046                generation: session.session.generation,
22047            });
22048            current.snapshot()
22049        };
22050        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22051        let receipt = managed_spawn_receipt(
22052            &resolved,
22053            shared.incarnation_id,
22054            session,
22055            snapshot,
22056            None,
22057            None,
22058            None,
22059        );
22060        assert!(matches!(
22061            shared
22062                .acquire_durable_managed_spawn_v2(
22063                    &request,
22064                    managed_spawn_request_digest_v2(&request).unwrap(),
22065                )
22066                .unwrap(),
22067            DurableManagedSpawnReplayDecision::Reserved,
22068        ));
22069        {
22070            let _transaction = shared
22071                .state_transaction
22072                .lock()
22073                .unwrap_or_else(|poisoned| poisoned.into_inner());
22074            shared
22075                .link_durable_managed_spawn_v2_lease_locked(
22076                    &request.spawn_spec.idempotency_key,
22077                    &lease.lease_id,
22078                )
22079                .unwrap();
22080            shared.persist_state_locked().unwrap();
22081        }
22082        shared
22083            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22084            .unwrap();
22085        let legacy_same_key = ManagedWorktreeSpawnRequest {
22086            spawn_spec: request.spawn_spec.clone(),
22087            worktree_profile_id: request.worktree_profile_id.clone(),
22088        };
22089        shared.remember_managed_spawn_attempt(
22090            Some(&request.spawn_spec.idempotency_key),
22091            legacy_same_key.clone(),
22092            Ok(receipt.clone()),
22093            Instant::now(),
22094        );
22095        assert!(shared
22096            .replay_managed_spawn(&legacy_same_key, Instant::now())
22097            .unwrap()
22098            .is_none());
22099        drop(shared);
22100
22101        let mut loaded = session_registry::load(
22102            Some(&state_path),
22103            &NodeId::new("node-terminal-test").unwrap(),
22104        )
22105        .unwrap();
22106        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22107        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22108        *reopened
22109            .managed_worktrees
22110            .lock()
22111            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22112            ManagedWorktreeRegistry::from_records(
22113                std::mem::take(&mut loaded.managed_worktrees),
22114                std::mem::take(&mut loaded.managed_worktree_tombstones),
22115            )
22116            .unwrap();
22117        *reopened
22118            .managed_spawn_replays
22119            .lock()
22120            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22121            .managed_spawn_replays
22122            .into_iter()
22123            .map(|record| (record.idempotency_key.clone(), record))
22124            .collect();
22125        let lease_count_before = reopened
22126            .managed_worktrees
22127            .lock()
22128            .unwrap_or_else(|poisoned| poisoned.into_inner())
22129            .records()
22130            .len();
22131
22132        assert_eq!(
22133            reopened
22134                .spawn_managed_worktree_v2(request.clone())
22135                .await
22136                .unwrap(),
22137            receipt,
22138        );
22139        assert_eq!(
22140            reopened
22141                .managed_worktrees
22142                .lock()
22143                .unwrap_or_else(|poisoned| poisoned.into_inner())
22144                .records()
22145                .len(),
22146            lease_count_before,
22147        );
22148        let mut changed = request;
22149        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22150        let failure = reopened
22151            .spawn_managed_worktree_v2(changed)
22152            .await
22153            .unwrap_err();
22154        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22155        assert_eq!(
22156            reopened
22157                .managed_worktrees
22158                .lock()
22159                .unwrap_or_else(|poisoned| poisoned.into_inner())
22160                .records()
22161                .len(),
22162            lease_count_before,
22163        );
22164        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22165        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22166        let failure = reopened
22167            .spawn_managed_worktree_v2(new_key)
22168            .await
22169            .unwrap_err();
22170        assert_eq!(
22171            failure.code,
22172            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22173        );
22174        assert_eq!(
22175            reopened
22176                .managed_worktrees
22177                .lock()
22178                .unwrap_or_else(|poisoned| poisoned.into_inner())
22179                .records()
22180                .len(),
22181            lease_count_before,
22182        );
22183        assert_eq!(
22184            reopened
22185                .managed_spawn_replays
22186                .lock()
22187                .unwrap_or_else(|poisoned| poisoned.into_inner())
22188                .len(),
22189            1,
22190        );
22191        std::fs::remove_dir_all(root).unwrap();
22192    }
22193
22194    #[test]
22195    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22196        let shared = terminal_test_shared();
22197        let request = ManagedWorktreeSpawnRequest {
22198            spawn_spec: spawn_spec_fixture(),
22199            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22200        };
22201        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22202        shared.remember_managed_spawn(
22203            request.clone(),
22204            Err(expected.clone()),
22205            Instant::now(),
22206        );
22207
22208        assert_eq!(
22209            shared
22210                .replay_managed_spawn(&request, Instant::now())
22211                .unwrap()
22212                .unwrap(),
22213            Err(expected),
22214        );
22215        assert!(shared
22216            .managed_spawn_replays
22217            .lock()
22218            .unwrap_or_else(|poisoned| poisoned.into_inner())
22219            .is_empty());
22220    }
22221
22222    #[tokio::test]
22223    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22224        let shared = terminal_test_shared();
22225        let lease = install_managed_lease(&shared);
22226        let raw = shared.spawn_session(
22227            lease.workspace_id.clone(),
22228            agent("claude"),
22229            SessionMode::Pty,
22230            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22231            None,
22232        ).await.unwrap_err();
22233        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22234        let mut spec = spawn_spec_fixture();
22235        spec.target.workspace_id = lease.workspace_id.clone();
22236        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22237        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22238        let mut selected = spawn_spec_fixture();
22239        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22240        selected.target.worktree_id = Some(lease.workspace_id);
22241        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22242        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22243        assert!(shared.handle.snapshot().sessions.is_empty());
22244    }
22245
22246    #[test]
22247    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22248        let shared = terminal_test_shared();
22249        let lease = install_managed_lease(&shared);
22250        let snapshot = snapshot_for_wire(
22251            &shared, true, true, true, false, true, true, false, false, true,
22252        );
22253        assert!(snapshot.managed_worktrees.is_empty());
22254        assert!(snapshot.workspaces.iter().all(|workspace| {
22255            workspace.managed_worktree_profiles.is_none()
22256        }));
22257        let mut reply = ResponseEnvelope {
22258            request_id: 1,
22259            result: Ok(NodeResponse::Resync {
22260                event_sequence: 1,
22261                oldest_available_sequence: 1,
22262                snapshot: shared.snapshot(),
22263                events: vec![NodeEventEnvelope {
22264                    sequence: 1,
22265                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22266                }],
22267            }),
22268        };
22269        project_response_without_managed_worktrees(&mut reply);
22270        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22271            panic!("projection changed the response kind");
22272        };
22273        assert!(snapshot.managed_worktrees.is_empty());
22274        assert!(events.is_empty());
22275        let mut git = git_snapshot_for_parser();
22276        git.branch = Some(lease.branch.clone());
22277        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22278            lease_id: lease.lease_id.clone(),
22279            source_workspace_id: lease.source_workspace_id.clone(),
22280            branch: lease.branch.clone(),
22281            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22282            active_session_count: 1,
22283            managed_record_count: 0,
22284        });
22285        let mut inspection_reply = ResponseEnvelope {
22286            request_id: 2,
22287            result: Ok(NodeResponse::WorkspaceInspected {
22288                inspection: WorkspaceInspection {
22289                    workspace_id: lease.workspace_id.clone(),
22290                    entries: Vec::new(),
22291                    tree_truncated: false,
22292                    git,
22293                    truncation: None,
22294                },
22295            }),
22296        };
22297        project_response_without_managed_worktrees(&mut inspection_reply);
22298        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22299            panic!("projection changed the inspection response kind");
22300        };
22301        assert!(inspection.git.managed_worktree.is_none());
22302        let failure = managed_git_worktree_failure(GitWorktreeError {
22303            kind: GitWorktreeErrorKind::Failed,
22304            message: r"git failed at C:\private\managed-target".to_owned(),
22305        });
22306        assert!(!failure.message.contains("private"));
22307        assert!(!failure.message.contains("managed-target"));
22308    }
22309
22310    #[tokio::test]
22311    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22312        let root = temporary_workspace_root("managed-restart");
22313        let source = root.join("source");
22314        let allocation = root.join("allocation");
22315        std::fs::create_dir_all(&source).unwrap();
22316        std::fs::create_dir_all(&allocation).unwrap();
22317        run_test_git(&source, &["init"]);
22318        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22319        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22320        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22321        run_test_git(&source, &["add", "seed.txt"]);
22322        run_test_git(&source, &["commit", "-m", "seed"]);
22323        let profile = ManagedWorktreeProfile::new(
22324            WorktreeProfileId::new("default").unwrap(),
22325            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22326            &allocation,
22327            "gate4agent",
22328            "HEAD",
22329            ManagedWorktreeRetention::Retain,
22330        ).unwrap();
22331        let workspace = WorkspaceConfig::new(
22332            WorkspaceId::new("primary").unwrap(),
22333            &source,
22334        ).unwrap()
22335            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22336            .with_managed_worktree_profile(profile.clone()).unwrap();
22337        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22338        let source_text = workspace.canonical_root().to_owned();
22339        let target_text = target.to_string_lossy().into_owned();
22340        let created = create_git_worktree(
22341            &source_text,
22342            &target_text,
22343            "gate4agent/mw-restart",
22344            Some("HEAD"),
22345        ).await.unwrap();
22346        let lease = ManagedWorktreeLeaseRecord {
22347            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22348            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22349            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22350            profile_id: profile.profile_id().clone(),
22351            profile_revision: profile.revision().clone(),
22352            target_root: created.path.clone(),
22353            branch: "gate4agent/mw-restart".to_owned(),
22354            base_commit: created.head.clone(),
22355            expected_head: None,
22356            retention: ManagedWorktreeRetention::Retain,
22357            state: ManagedWorktreeLeaseState::Allocating,
22358            session_holders: Vec::new(),
22359            record_holders: Vec::new(),
22360            cleanup_failure: None,
22361            created_at_unix_ms: 1,
22362            updated_at_unix_ms: 1,
22363        };
22364        let catalog = active_registry().unwrap();
22365        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22366        let shared = NodeShared::new_with_incarnation(
22367            handle,
22368            "fixture-token".to_owned(),
22369            NodeId::new("node-managed-restart").unwrap(),
22370            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22371            vec![workspace],
22372            vec![agent("claude")],
22373            ProviderRuntimeStatuses::default(),
22374            None,
22375            Vec::new(),
22376            Vec::new(),
22377            SpawnProfileRegistry::default(),
22378            None,
22379            None,
22380            None,
22381            None,
22382            Vec::new(),
22383            vec![lease.clone()],
22384            Vec::new(),
22385            Vec::new(),
22386            None,
22387            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22388        );
22389        shared.reconcile_managed_worktrees().await;
22390        let retained = shared.managed_worktrees.lock()
22391            .unwrap_or_else(|poisoned| poisoned.into_inner())
22392            .get(&lease.lease_id).unwrap().clone();
22393        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22394        assert!(shared.workspaces.read()
22395            .unwrap_or_else(|poisoned| poisoned.into_inner())
22396            .contains_key(&lease.workspace_id));
22397        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22398        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22399        assert!(!Path::new(&created.path).exists());
22400        std::fs::remove_dir_all(root).unwrap();
22401    }
22402
22403    fn run_test_git(root: &Path, arguments: &[&str]) {
22404        let output = std::process::Command::new("git")
22405            .arg("-C").arg(root).args(arguments).output().unwrap();
22406        assert!(
22407            output.status.success(),
22408            "git {:?} failed: {}",
22409            arguments,
22410            String::from_utf8_lossy(&output.stderr),
22411        );
22412    }
22413
22414    #[tokio::test]
22415    async fn spawn_spec_validation_fails_before_session_mutation() {
22416        let shared = terminal_test_shared();
22417        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22418            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22419        }));
22420        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22421            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22422        }));
22423        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22424            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22425        }));
22426
22427        let mut target_mismatch = spawn_spec_fixture();
22428        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22429        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22430        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22431
22432        let mut unknown_profile = spawn_spec_fixture();
22433        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22434        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22435        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22436        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22437
22438        let mut revision_mismatch = spawn_spec_fixture();
22439        revision_mismatch.idempotency_key =
22440            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22441        revision_mismatch.expected_profile_revision =
22442            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22443        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22444        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22445
22446        let mut unsupported_materializer = spawn_spec_fixture();
22447        unsupported_materializer.idempotency_key =
22448            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22449        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22450            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22451        };
22452        let failure = shared
22453            .spawn_from_spec(unsupported_materializer)
22454            .await
22455            .unwrap_err();
22456        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22457
22458        let failure = shared
22459            .spawn_session_with_deadline(
22460                WorkspaceId::new("primary").unwrap(),
22461                agent("claude"),
22462                SessionMode::Pty,
22463                gate4agent_types::TerminalSize {
22464                    rows: 24,
22465                    columns: 80,
22466                },
22467                None,
22468                None,
22469                None,
22470                None,
22471                None,
22472                None,
22473                SpawnRecordPolicy::ProviderIdentityOnly,
22474                Some(Instant::now()),
22475                &[],
22476                None,
22477                ApprovalLevel::default(),
22478                None,
22479                None,
22480            )
22481            .await
22482            .unwrap_err();
22483        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22484        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22485        assert!(shared.handle.snapshot().sessions.is_empty());
22486    }
22487
22488    #[test]
22489    fn worktree_selection_requires_its_separate_negotiated_capability() {
22490        let mut spec = spawn_spec_fixture();
22491        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22492        let request = NodeRequest::SpawnSpec { spec };
22493        let spawn_spec_capability =
22494            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22495        assert!(request_uses_unnegotiated_capability(
22496            &request,
22497            &[spawn_spec_capability.clone()],
22498        ));
22499        let worktree_capability =
22500            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22501        assert!(request_uses_unnegotiated_capability(
22502            &request,
22503            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22504        ));
22505        let profile_revision_capability =
22506            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22507        assert!(request_uses_unnegotiated_capability(
22508            &request,
22509            &[
22510                spawn_spec_capability.clone(),
22511                worktree_capability.clone(),
22512                profile_revision_capability.clone(),
22513            ],
22514        ));
22515        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22516        // `Inherit`, not `Clear` — the profile's own defaults might still
22517        // materialize a bundle or a context pack, so both capabilities stay
22518        // conservatively required until negotiated (see
22519        // `requires_session_bundle_materialization_capability` /
22520        // `requires_history_context_pack_capability`).
22521        let bundle_materialization_capability =
22522            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22523        assert!(request_uses_unnegotiated_capability(
22524            &request,
22525            &[
22526                spawn_spec_capability.clone(),
22527                worktree_capability.clone(),
22528                profile_revision_capability.clone(),
22529                bundle_materialization_capability.clone(),
22530            ],
22531        ));
22532        let history_context_pack_capability =
22533            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22534        assert!(!request_uses_unnegotiated_capability(
22535            &request,
22536            &[
22537                spawn_spec_capability,
22538                worktree_capability,
22539                profile_revision_capability,
22540                bundle_materialization_capability,
22541                history_context_pack_capability,
22542            ],
22543        ));
22544    }
22545
22546    #[tokio::test]
22547    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22548        let mut shared = terminal_test_shared();
22549        let spec = spawn_spec_fixture();
22550        let receipt = shared
22551            .resolve_spawn_spec(&spec)
22552            .unwrap()
22553            .receipt(shared.incarnation_id, terminal_address(1));
22554        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22555
22556        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22557        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22558        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22559
22560        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22561
22562        let mut revision_conflicting = spec.clone();
22563        revision_conflicting.expected_profile_revision =
22564            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22565        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22566        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22567
22568        let mut stale_cache_miss = spec.clone();
22569        stale_cache_miss.idempotency_key =
22570            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22571        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22572        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22573
22574        let mut conflicting = spec;
22575        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22576        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22577        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22578        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22579        assert!(shared.handle.snapshot().sessions.is_empty());
22580    }
22581
22582    #[tokio::test]
22583    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22584        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22585        let mut spec = spawn_spec_fixture();
22586        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22587        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22588        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22589
22590        let failure = shared
22591            .create_worktree(
22592                WorkspaceId::new("primary").unwrap(),
22593                WorkspaceId::new("new-worktree").unwrap(),
22594                "not-an-absolute-path".to_owned(),
22595                "invalid branch".to_owned(),
22596                None,
22597            )
22598            .await
22599            .unwrap_err();
22600        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22601
22602        let failure = shared
22603            .remove_worktree(
22604                WorkspaceId::new("primary").unwrap(),
22605                "not-an-absolute-path".to_owned(),
22606            )
22607            .await
22608            .unwrap_err();
22609        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22610        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22611        assert!(shared.handle.snapshot().sessions.is_empty());
22612    }
22613
22614    #[tokio::test]
22615    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22616        let root = temporary_workspace_root("manual-worktree-compensation");
22617        let source = root.join("source");
22618        let target = root.join("target");
22619        let invalid_state_parent = root.join("state-parent-is-a-file");
22620        let invalid_state_path = invalid_state_parent.join("state.json");
22621        std::fs::create_dir_all(&source).unwrap();
22622        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22623        run_test_git(&source, &["init"]);
22624        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22625        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22626        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22627        run_test_git(&source, &["add", "seed.txt"]);
22628        run_test_git(&source, &["commit", "-m", "seed"]);
22629
22630        let catalog = active_registry().unwrap();
22631        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22632        let workspace = WorkspaceConfig::new(
22633            WorkspaceId::new("primary").unwrap(),
22634            &source,
22635        ).unwrap();
22636        let mut shared = NodeShared::new(
22637            handle,
22638            "fixture-token".to_owned(),
22639            NodeId::new("node-worktree-compensation").unwrap(),
22640            vec![workspace],
22641            vec![agent("claude")],
22642        );
22643        shared.state_path = Some(invalid_state_path);
22644
22645        let error = shared.create_worktree(
22646            WorkspaceId::new("primary").unwrap(),
22647            WorkspaceId::new("compensated").unwrap(),
22648            target.to_string_lossy().into_owned(),
22649            "gate4agent/compensated".to_owned(),
22650            Some("HEAD".to_owned()),
22651        ).await.unwrap_err();
22652
22653        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22654        assert!(!target.exists());
22655        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22656            .iter()
22657            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22658        std::fs::remove_dir_all(root).unwrap();
22659    }
22660
22661    #[test]
22662    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22663        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22664            NativeGitWorktreeSnapshot {
22665                path: path.to_owned(),
22666                head: "0123456789abcdef".to_owned(),
22667                branch: Some("refs/heads/test".to_owned()),
22668                is_bare,
22669                is_main,
22670                locked: false,
22671                lock_reason: None,
22672                prunable: false,
22673                prunable_reason: None,
22674                workspace_id: None,
22675            }
22676        }
22677
22678        let source = r"C:\repo";
22679        let selected = r"C:\repo-worktree";
22680        let listed = vec![
22681            worktree(source, true, false),
22682            worktree(selected, false, false),
22683        ];
22684        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22685
22686        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22687        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22688
22689        let bare = vec![
22690            worktree(source, true, false),
22691            worktree(selected, false, true),
22692        ];
22693        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22694        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22695
22696        let foreign_failure =
22697            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22698        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22699    }
22700
22701    fn terminal_frame(sequence: u64) -> TerminalFrame {
22702        TerminalFrame {
22703            sequence,
22704            size: gate4agent_types::TerminalSize {
22705                rows: 24,
22706                columns: 80,
22707            },
22708            cursor_row: 2,
22709            cursor_column: 3,
22710            contents: format!("frame-{sequence}"),
22711            formatted: vec![1, 2, 3],
22712            scrollback_formatted: Vec::new(),
22713            alternate_screen: false,
22714            mouse_protocol_enabled: false,
22715            mouse_protocol_encoding: Default::default(),
22716            produced_at_unix_ms: 0,
22717            screen_state: gate4agent_types::PtyScreenState::default(),
22718            bracketed_paste: None,
22719        }
22720    }
22721
22722    #[test]
22723    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22724        let shared = terminal_test_shared();
22725        let mut receiver = shared.terminal_event_tx.subscribe();
22726        let original = terminal_address(1);
22727
22728        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22729        let first = receiver.try_recv().unwrap();
22730        assert_eq!(first.len(), 1);
22731        assert!(matches!(
22732            &first[0].event,
22733            NodeEvent::TerminalFrame { address, frame }
22734                if address == &original && frame.sequence == 7
22735        ));
22736
22737        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22738        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22739        assert!(matches!(
22740            receiver.try_recv(),
22741            Err(broadcast::error::TryRecvError::Empty)
22742        ));
22743
22744        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22745        let advanced = receiver.try_recv().unwrap();
22746        assert_eq!(advanced[0].sequence, 2);
22747
22748        let resumed = terminal_address(2);
22749        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22750        let reset = receiver.try_recv().unwrap();
22751        assert_eq!(reset[0].sequence, 3);
22752        assert!(matches!(
22753            &reset[0].event,
22754            NodeEvent::TerminalFrame { address, frame }
22755                if address == &resumed && frame.sequence == 1
22756        ));
22757        shared.clear_terminal_frame_watermark(&resumed);
22758        assert!(!shared
22759            .terminal_frame_watermarks
22760            .lock()
22761            .unwrap_or_else(|poisoned| poisoned.into_inner())
22762            .contains_key(&resumed.session.instance_id));
22763    }
22764
22765    #[test]
22766    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22767        let shared = terminal_test_shared();
22768        let mut receiver = shared.terminal_event_tx.subscribe();
22769        let address = terminal_address(1);
22770        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22771
22772        for sequence in 1..=count {
22773            shared.publish_terminal_frame_candidates(vec![(
22774                address.clone(),
22775                terminal_frame(sequence),
22776            )]);
22777        }
22778
22779        let history = shared
22780            .history
22781            .lock()
22782            .unwrap_or_else(|poisoned| poisoned.into_inner());
22783        assert!(history.events.is_empty());
22784        assert_eq!(history.last_sequence, count);
22785        assert_eq!(history.replay_floor_sequence, 1);
22786        drop(history);
22787        let NodeResponse::Resync {
22788            event_sequence,
22789            oldest_available_sequence,
22790            events,
22791            ..
22792        } = shared.resync(0) else {
22793            unreachable!("resync helper returned another response")
22794        };
22795        assert_eq!(event_sequence, count);
22796        assert_eq!(oldest_available_sequence, 1);
22797        assert!(events.is_empty());
22798        assert!(matches!(
22799            resync_required_event(&shared).event,
22800            NodeEvent::ResyncRequired {
22801                oldest_available_sequence: 1,
22802            }
22803        ));
22804        assert!(matches!(
22805            receiver.try_recv(),
22806            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22807        ));
22808        let latest = receiver.try_recv().unwrap();
22809        assert_eq!(latest.len(), 1);
22810        assert_eq!(latest[0].sequence, count);
22811    }
22812
22813    #[test]
22814    fn real_durable_eviction_advances_authoritative_replay_floor() {
22815        let shared = terminal_test_shared();
22816        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22817            shared.publish(NodeEvent::ControllerChanged { controller: None });
22818        }
22819
22820        let NodeResponse::Resync {
22821            event_sequence,
22822            oldest_available_sequence,
22823            events,
22824            ..
22825        } = shared.resync(0) else {
22826            unreachable!("resync helper returned another response")
22827        };
22828        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22829        assert_eq!(oldest_available_sequence, 2);
22830        assert_eq!(events.first().map(|event| event.sequence), Some(2));
22831        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22832        assert!(matches!(
22833            resync_required_event(&shared).event,
22834            NodeEvent::ResyncRequired {
22835                oldest_available_sequence: 2,
22836            }
22837        ));
22838    }
22839
22840    #[test]
22841    fn legacy_terminal_frame_connection_has_no_live_subscription() {
22842        let shared = terminal_test_shared();
22843        let without_capability = terminal_event_subscription(&shared, false);
22844        let with_capability = terminal_event_subscription(&shared, true);
22845        assert!(without_capability.is_none());
22846        assert!(with_capability.is_some());
22847        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22848            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22849        }));
22850    }
22851
22852    #[test]
22853    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22854        let mut pending = Vec::new();
22855        let mut event_sequence = 0_u64;
22856        for frame_sequence in 1..=4 {
22857            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22858                event_sequence += 1;
22859                let mut address = terminal_address(1);
22860                address.session.instance_id = AgentInstanceId(instance);
22861                queue_connection_event(
22862                    &mut pending,
22863                    NodeEventEnvelope {
22864                        sequence: event_sequence,
22865                        event: NodeEvent::TerminalFrame {
22866                            address,
22867                            frame: terminal_frame(frame_sequence),
22868                        },
22869                    },
22870                    0,
22871                );
22872            }
22873        }
22874        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22875        pending.sort_unstable_by_key(|event| event.sequence);
22876
22877        let mut uninterrupted = pending.clone();
22878        let first_burst = connection_event_burst_len(uninterrupted.len());
22879        uninterrupted.drain(..first_burst);
22880        let second_burst = connection_event_burst_len(uninterrupted.len());
22881        uninterrupted.drain(..second_burst);
22882        assert!(uninterrupted.is_empty());
22883
22884        let (frame_tx, mut frame_rx) =
22885            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22886        frame_tx
22887            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22888                request_id: 77,
22889                request: NodeRequest::Snapshot,
22890            })))
22891            .unwrap();
22892        let burst_len = connection_event_burst_len(pending.len());
22893        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22894        pending.drain(..burst_len);
22895        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22896        assert!(matches!(
22897            frame_rx.try_recv(),
22898            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22899                request_id: 77,
22900                request: NodeRequest::Snapshot,
22901            })))
22902        ));
22903    }
22904
22905    #[test]
22906    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22907        let mut pending = Vec::new();
22908        for sequence in [7_u64, 8] {
22909            let mut address = terminal_address(1);
22910            address.session.instance_id = AgentInstanceId(sequence);
22911            queue_connection_event(
22912                &mut pending,
22913                NodeEventEnvelope {
22914                    sequence,
22915                    event: NodeEvent::TerminalFrame {
22916                        address,
22917                        frame: terminal_frame(sequence),
22918                    },
22919                },
22920                0,
22921            );
22922        }
22923        let mut discard_through = 0;
22924        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22925        assert!(pending.is_empty());
22926
22927        let replayed = NodeEventEnvelope {
22928            sequence: 8,
22929            event: NodeEvent::TerminalFrame {
22930                address: terminal_address(1),
22931                frame: terminal_frame(8),
22932            },
22933        };
22934        let advanced = NodeEventEnvelope {
22935            sequence: 9,
22936            event: NodeEvent::TerminalFrame {
22937                address: terminal_address(2),
22938                frame: terminal_frame(1),
22939            },
22940        };
22941        queue_connection_event_batch(
22942            &mut pending,
22943            &[replayed, advanced.clone()],
22944            discard_through,
22945        );
22946        assert_eq!(pending, vec![advanced]);
22947    }
22948
22949    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22950        ManagedSessionRecord {
22951            record_id: SessionRecordId::new(record_id).unwrap(),
22952            display_name: format!("{provider} record"),
22953            provider: agent(provider),
22954            mode: SessionMode::Pty,
22955            state: ManagedSessionState::Unavailable,
22956            workspace_id: WorkspaceId::new("primary").unwrap(),
22957            canonical_root: opaque_windows_path(
22958                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22959            ),
22960            provider_session: None,
22961            active_session: None,
22962            environment_profile: None,
22963            bundle: None,
22964            context_id: None,
22965            context: None,
22966            exported_context: None,
22967            task_binding: None,
22968            created_at_unix_ms: 1,
22969            updated_at_unix_ms: 1,
22970            last_error: None,
22971        }
22972    }
22973
22974    fn retention_test_record(
22975        workspace_id: &str,
22976        record_id: &str,
22977        state: ManagedSessionState,
22978        updated_at_unix_ms: u64,
22979    ) -> ManagedSessionRecord {
22980        ManagedSessionRecord {
22981            record_id: SessionRecordId::new(record_id).unwrap(),
22982            display_name: format!("{record_id} display"),
22983            provider: agent("claude"),
22984            mode: SessionMode::Pty,
22985            state,
22986            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22987            canonical_root: opaque_windows_path(
22988                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22989            ),
22990            provider_session: None,
22991            active_session: None,
22992            environment_profile: None,
22993            bundle: None,
22994            context_id: None,
22995            context: None,
22996            exported_context: None,
22997            task_binding: None,
22998            created_at_unix_ms: updated_at_unix_ms,
22999            updated_at_unix_ms,
23000            last_error: None,
23001        }
23002    }
23003
23004    /// Two workspaces of three `Unavailable` records each, staggered by
23005    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23006    /// `workspace-a` (all as old as that workspace's oldest real record --
23007    /// if the sweep's state gate ever leaked, these would be the first
23008    /// thing it wrongly retired). Ages, oldest to newest:
23009    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23010    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23011    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23012        vec![
23013            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23014            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23015            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23016            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23017            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23018            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23019            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23020            retention_test_record(
23021                "workspace-a",
23022                "decoy-pending",
23023                ManagedSessionState::IdentityPending,
23024                now - 500_000,
23025            ),
23026            retention_test_record(
23027                "workspace-a",
23028                "decoy-dormant",
23029                ManagedSessionState::Dormant,
23030                now - 500_000,
23031            ),
23032        ]
23033    }
23034
23035    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23036        let root = temporary_workspace_root("session-record-retention");
23037        std::fs::create_dir_all(&root).unwrap();
23038        let catalog = active_registry().unwrap();
23039        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23040        let workspace_a =
23041            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23042        let workspace_b =
23043            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23044        let mut shared = NodeShared::new(
23045            handle,
23046            "fixture-token".to_owned(),
23047            NodeId::new("node-retention-test").unwrap(),
23048            vec![workspace_a, workspace_b],
23049            vec![agent("claude")],
23050        );
23051        shared.state_path = Some(root.join("node-state-v10.json"));
23052        let now = unix_time_ms();
23053        for record in retention_fixture_records(now) {
23054            shared.insert_record(record).unwrap();
23055        }
23056        shared
23057            .persist_state_locked()
23058            .expect("fixture-only, empty-history persist against a fresh temp state path");
23059        (shared, runtime, root, now)
23060    }
23061
23062    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23063        shared
23064            .history
23065            .lock()
23066            .unwrap_or_else(|poisoned| poisoned.into_inner())
23067            .events
23068            .iter()
23069            .filter_map(|envelope| match &envelope.event {
23070                NodeEvent::SessionRecordRemoved { record_id } => {
23071                    Some(record_id.as_str().to_owned())
23072                }
23073                _ => None,
23074            })
23075            .collect()
23076    }
23077
23078    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23079        shared
23080            .session_records
23081            .lock()
23082            .unwrap_or_else(|poisoned| poisoned.into_inner())
23083            .records
23084            .keys()
23085            .map(|record_id| record_id.as_str().to_owned())
23086            .collect()
23087    }
23088
23089    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23090        values.iter().map(|value| (*value).to_owned()).collect()
23091    }
23092
23093    #[tokio::test]
23094    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23095        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23096        shared.session_record_retention = SessionRecordRetentionConfig {
23097            age_ms: 0,
23098            keep_per_workspace: 1,
23099        };
23100        shared.retire_unavailable_session_records().await;
23101
23102        assert_eq!(
23103            retention_remaining_record_ids(&shared),
23104            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23105        );
23106        assert_eq!(
23107            retention_removed_record_ids(&shared),
23108            owned(&["a1", "a2", "b1", "b2"]),
23109        );
23110        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23111
23112        drop(shared);
23113        drop(runtime);
23114        std::fs::remove_dir_all(root).unwrap();
23115    }
23116
23117    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23118    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23119    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23120    /// 200s old against a 250s threshold) even though it fails the keep-N
23121    /// test too, proving retirement needs BOTH, not either.
23122    #[tokio::test]
23123    async fn session_record_retention_both_set_retires_only_the_intersection() {
23124        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23125        shared.session_record_retention = SessionRecordRetentionConfig {
23126            age_ms: 250_000,
23127            keep_per_workspace: 1,
23128        };
23129        shared.retire_unavailable_session_records().await;
23130
23131        assert_eq!(
23132            retention_remaining_record_ids(&shared),
23133            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23134        );
23135        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23136        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23137
23138        drop(shared);
23139        drop(runtime);
23140        std::fs::remove_dir_all(root).unwrap();
23141    }
23142
23143    #[tokio::test]
23144    async fn session_record_retention_both_disabled_is_a_no_op() {
23145        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23146        shared.session_record_retention = SessionRecordRetentionConfig::default();
23147        shared.retire_unavailable_session_records().await;
23148
23149        assert_eq!(
23150            retention_remaining_record_ids(&shared),
23151            owned(&[
23152                "a1", "a2", "a3", "b1", "b2", "b3",
23153                "decoy-live", "decoy-pending", "decoy-dormant",
23154            ]),
23155        );
23156        assert!(retention_removed_record_ids(&shared).is_empty());
23157        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23158
23159        drop(shared);
23160        drop(runtime);
23161        std::fs::remove_dir_all(root).unwrap();
23162    }
23163
23164    #[test]
23165    fn session_record_context_export_selects_one_exact_history_identity() {
23166        let candidates = vec![
23167            HistoryCandidateSummary {
23168                id: "candidate-other".to_owned(),
23169                session_id_hint: "provider-other".to_owned(),
23170                modified_at_unix_ms: Some(1),
23171            },
23172            HistoryCandidateSummary {
23173                id: "candidate-exact".to_owned(),
23174                session_id_hint: "provider-exact".to_owned(),
23175                modified_at_unix_ms: Some(2),
23176            },
23177        ];
23178        assert_eq!(
23179            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23180            "candidate-exact",
23181        );
23182    }
23183
23184    #[test]
23185    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23186        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23187            id: id.to_owned(),
23188            session_id_hint: hint.to_owned(),
23189            modified_at_unix_ms: None,
23190        };
23191        let missing = select_history_candidate(
23192            &[candidate("candidate-other", "provider-other")],
23193            Some("provider-exact"),
23194        )
23195        .unwrap_err();
23196        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23197        let ambiguous = select_history_candidate(
23198            &[
23199                candidate("candidate-a", "provider-exact"),
23200                candidate("candidate-b", "provider-exact"),
23201            ],
23202            Some("provider-exact"),
23203        )
23204        .unwrap_err();
23205        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23206    }
23207
23208    /// The PTY-sidecar-observation shape (no fleet provider today, but
23209    /// capability-keyed rather than vendor-locked) never carries a verified
23210    /// `provider_session`; its record-bound export instead falls back to
23211    /// "exactly one discovered candidate" (see
23212    /// `export_context_pack_for_session_record_inner`). This is the same
23213    /// ambiguity refusal as the identity-filtered path, just without an
23214    /// identity to filter by.
23215    #[test]
23216    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23217        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23218            id: id.to_owned(),
23219            session_id_hint: hint.to_owned(),
23220            modified_at_unix_ms: None,
23221        };
23222        assert_eq!(
23223            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23224                .unwrap(),
23225            "candidate-sole",
23226        );
23227        let missing = select_history_candidate(&[], None).unwrap_err();
23228        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23229        let ambiguous = select_history_candidate(
23230            &[
23231                candidate("candidate-a", "sidecar-session-a"),
23232                candidate("candidate-b", "sidecar-session-b"),
23233            ],
23234            None,
23235        )
23236        .unwrap_err();
23237        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23238    }
23239
23240    #[test]
23241    fn session_record_context_export_revalidation_rejects_record_change() {
23242        let session = terminal_address(1);
23243        let identity = ProviderSessionIdentity {
23244            key: ProviderSessionKey::SessionId,
23245            id: "provider-exact".to_owned(),
23246            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23247        };
23248        let mut expected = record("claude", "record-context-export");
23249        expected.state = ManagedSessionState::Live;
23250        expected.provider_session = Some(identity.clone());
23251        expected.active_session = Some(session.clone());
23252        let binding = SessionBinding {
23253            workspace_id: session.workspace_id.clone(),
23254            generation: session.session.generation,
23255            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23256            pending_resume: None,
23257            record_id: Some(expected.record_id.clone()),
23258            managed_worktree_lease_id: None,
23259            environment_profile: None,
23260            bundle: None,
23261            context: None,
23262            materialization_id: None,
23263        };
23264        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23265        assert!(session_record_context_export_binding_is_exact(
23266            &expected,
23267            &expected,
23268            Some(&identity),
23269            &session,
23270            &binding,
23271            &expected.provider,
23272            &current_root,
23273            false,
23274        ));
23275        let mut changed = expected.clone();
23276        changed.updated_at_unix_ms += 1;
23277        assert!(!session_record_context_export_binding_is_exact(
23278            &expected,
23279            &changed,
23280            Some(&identity),
23281            &session,
23282            &binding,
23283            &expected.provider,
23284            &current_root,
23285            false,
23286        ));
23287        // A same-instant timestamp bump alone (state and active_session
23288        // otherwise unchanged) is not the clean-detachment transition
23289        // either, even with the reactive flag on: only the exact Live ->
23290        // Dormant + active_session cleared shape qualifies.
23291        assert!(!session_record_context_export_binding_is_exact(
23292            &expected,
23293            &changed,
23294            Some(&identity),
23295            &session,
23296            &binding,
23297            &expected.provider,
23298            &current_root,
23299            true,
23300        ));
23301        let mut detached = expected.clone();
23302        detached.state = ManagedSessionState::Dormant;
23303        detached.active_session = None;
23304        detached.updated_at_unix_ms += 1;
23305        assert!(!session_record_context_export_binding_is_exact(
23306            &expected,
23307            &detached,
23308            Some(&identity),
23309            &session,
23310            &binding,
23311            &expected.provider,
23312            &current_root,
23313            false,
23314        ));
23315        assert!(session_record_context_export_binding_is_exact(
23316            &expected,
23317            &detached,
23318            Some(&identity),
23319            &session,
23320            &binding,
23321            &expected.provider,
23322            &current_root,
23323            true,
23324        ));
23325    }
23326
23327    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23328    /// for a record whose provider never carries a verified identity (the
23329    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23330    /// declares one today): `identity` is `None` throughout, and the
23331    /// exactness check falls back to comparing `provider_session` itself,
23332    /// which must still be `None` on both sides.
23333    #[test]
23334    fn session_record_context_export_binding_is_exact_without_identity() {
23335        let session = terminal_address(1);
23336        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23337        expected.state = ManagedSessionState::Live;
23338        expected.active_session = Some(session.clone());
23339        let binding = SessionBinding {
23340            workspace_id: session.workspace_id.clone(),
23341            generation: session.session.generation,
23342            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23343            pending_resume: None,
23344            record_id: Some(expected.record_id.clone()),
23345            managed_worktree_lease_id: None,
23346            environment_profile: None,
23347            bundle: None,
23348            context: None,
23349            materialization_id: None,
23350        };
23351        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23352        assert!(session_record_context_export_binding_is_exact(
23353            &expected,
23354            &expected,
23355            None,
23356            &session,
23357            &binding,
23358            &expected.provider,
23359            &current_root,
23360            false,
23361        ));
23362        // A record that gained an identity between the initial lookup and
23363        // this recheck no longer matches the `None` this shape expects to
23364        // keep seeing -- treated the same as any other mid-export drift.
23365        let mut drifted = expected.clone();
23366        drifted.provider_session = Some(ProviderSessionIdentity {
23367            key: ProviderSessionKey::SessionId,
23368            id: "unexpected-identity".to_owned(),
23369            transcript_path: None,
23370        });
23371        assert!(!session_record_context_export_binding_is_exact(
23372            &expected,
23373            &drifted,
23374            None,
23375            &session,
23376            &binding,
23377            &expected.provider,
23378            &current_root,
23379            false,
23380        ));
23381    }
23382
23383    #[test]
23384    fn session_record_context_export_preflight_requires_live_usable_source() {
23385        let mut source = record("claude", "record-context-export-preflight");
23386        source.state = ManagedSessionState::Live;
23387        assert!(session_record_context_export_source_is_usable(
23388            &source,
23389            &SessionStatus::Running,
23390            false,
23391        ));
23392        assert!(session_record_context_export_source_is_usable(
23393            &source,
23394            &SessionStatus::Exited { exit_code: Some(0) },
23395            false,
23396        ));
23397
23398        source.state = ManagedSessionState::Dormant;
23399        assert!(!session_record_context_export_source_is_usable(
23400            &source,
23401            &SessionStatus::Running,
23402            false,
23403        ));
23404        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23405        // the only caller that may treat a Dormant, cleanly-exited record as
23406        // still usable — the on-demand path above never does.
23407        assert!(session_record_context_export_source_is_usable(
23408            &source,
23409            &SessionStatus::Exited { exit_code: Some(0) },
23410            true,
23411        ));
23412        assert!(!session_record_context_export_source_is_usable(
23413            &source,
23414            &SessionStatus::Exited { exit_code: Some(0) },
23415            false,
23416        ));
23417        source.state = ManagedSessionState::Live;
23418        assert!(!session_record_context_export_source_is_usable(
23419            &source,
23420            &SessionStatus::Starting,
23421            false,
23422        ));
23423    }
23424
23425    #[test]
23426    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23427        let catalog = active_registry().unwrap();
23428        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23429        let workspace = WorkspaceConfig::new(
23430            WorkspaceId::new("primary").unwrap(),
23431            std::env::current_dir().unwrap(),
23432        )
23433        .unwrap();
23434        let shared = NodeShared::new(
23435            handle,
23436            "fixture-token".to_owned(),
23437            NodeId::new("node-context-export-conflict").unwrap(),
23438            vec![workspace],
23439            vec![agent("claude")],
23440        );
23441        let history = HistorySessionRecord {
23442            session_id: "provider-exact".to_owned(),
23443            title: None,
23444            cwd: None,
23445            model: None,
23446            message_count: 1,
23447            completed_turn_count: None,
23448            total_tokens: 0,
23449            messages: vec![gate4agent_types::HistoryMessageRecord {
23450                role: gate4agent_types::HistoryMessageRole::User,
23451                text: "bounded context".to_owned(),
23452            }],
23453        };
23454        let pack = NodeContextPack::export(
23455            ContextPackLineageReceipt {
23456                source_node_id: shared.node_id.clone(),
23457                source_session: terminal_address(1),
23458                source_provider: agent("claude"),
23459            },
23460            &history,
23461        )
23462        .unwrap();
23463        let context_id = pack.receipt().id.clone();
23464        let session = terminal_address(1);
23465        let identity = ProviderSessionIdentity {
23466            key: ProviderSessionKey::SessionId,
23467            id: history.session_id.clone(),
23468            transcript_path: None,
23469        };
23470        let mut expected = record("claude", "record-context-export-final-conflict");
23471        expected.state = ManagedSessionState::Live;
23472        expected.provider_session = Some(identity.clone());
23473        expected.active_session = Some(session.clone());
23474        let mut changed = expected.clone();
23475        changed.updated_at_unix_ms += 1;
23476        shared.insert_record(changed).unwrap();
23477
23478        let error = shared
23479            .commit_context_pack_for_session_record(
23480                &expected,
23481                Some(&identity),
23482                &session,
23483                "candidate-exact",
23484                &history,
23485                pack,
23486                false,
23487            )
23488            .unwrap_err();
23489
23490        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23491        assert!(shared
23492            .context_catalog
23493            .read()
23494            .unwrap_or_else(|poisoned| poisoned.into_inner())
23495            .get(&context_id)
23496            .is_none());
23497    }
23498
23499    #[tokio::test]
23500    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23501        let root = temporary_workspace_root("session-task-binding");
23502        std::fs::create_dir_all(&root).unwrap();
23503        let state_path = root.join("node-state.json");
23504        let catalog = active_registry().unwrap();
23505        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23506        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23507        let node_id = NodeId::new("node-1").unwrap();
23508        let mut shared = NodeShared::new(
23509            handle,
23510            "fixture-token".to_owned(),
23511            node_id.clone(),
23512            vec![workspace],
23513            vec![agent("claude")],
23514        );
23515        shared.state_path = Some(state_path.clone());
23516        let record = record("claude", "sr-task");
23517        let record_id = record.record_id.clone();
23518        shared.insert_record(record).unwrap();
23519        shared.persist_state().unwrap();
23520        shared
23521            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23522            .unwrap();
23523
23524        let minted = process_request_inner(
23525            &shared,
23526            77,
23527            ClientRole::Operator,
23528            NodeRequest::SetSessionTask {
23529                record_id: record_id.clone(),
23530                expected_revision: 0,
23531                target: SessionTaskTargetV1::New,
23532            },
23533        )
23534        .await
23535        .unwrap();
23536        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23537            panic!("session task mutation returned another response");
23538        };
23539        let minted_binding = minted.task_binding.clone().unwrap();
23540        let minted_task_id = minted_binding.task_id.clone().unwrap();
23541        assert_eq!(minted_binding.revision, 1);
23542        assert!(minted_task_id.as_str().starts_with("task-"));
23543        assert_eq!(minted_task_id.as_str().len(), 29);
23544
23545        let event_count = shared.current_sequence();
23546        let repeated = process_request_inner(
23547            &shared,
23548            77,
23549            ClientRole::Operator,
23550            NodeRequest::SetSessionTask {
23551                record_id: record_id.clone(),
23552                expected_revision: 1,
23553                target: SessionTaskTargetV1::Existing {
23554                    task_id: minted_task_id.clone(),
23555                },
23556            },
23557        )
23558        .await
23559        .unwrap();
23560        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23561        assert_eq!(shared.current_sequence(), event_count);
23562
23563        let stale = process_request_inner(
23564            &shared,
23565            77,
23566            ClientRole::Operator,
23567            NodeRequest::SetSessionTask {
23568                record_id: record_id.clone(),
23569                expected_revision: 0,
23570                target: SessionTaskTargetV1::Existing {
23571                    task_id: minted_task_id,
23572                },
23573            },
23574        )
23575        .await
23576        .unwrap_err();
23577        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23578
23579        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23580        let assigned = process_request_inner(
23581            &shared,
23582            77,
23583            ClientRole::Operator,
23584            NodeRequest::SetSessionTask {
23585                record_id: record_id.clone(),
23586                expected_revision: 1,
23587                target: SessionTaskTargetV1::Existing {
23588                    task_id: assigned_id.clone(),
23589                },
23590            },
23591        )
23592        .await
23593        .unwrap();
23594        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23595            panic!("session task assignment returned another response");
23596        };
23597        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23598        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23599
23600        let cleared = process_request_inner(
23601            &shared,
23602            77,
23603            ClientRole::Operator,
23604            NodeRequest::SetSessionTask {
23605                record_id: record_id.clone(),
23606                expected_revision: 2,
23607                target: SessionTaskTargetV1::Clear,
23608            },
23609        )
23610        .await
23611        .unwrap();
23612        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23613            panic!("session task clear returned another response");
23614        };
23615        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23616        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23617        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23618        assert_eq!(
23619            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23620            cleared.updated_at_unix_ms,
23621        );
23622
23623        let persisted = std::fs::read(&state_path).unwrap();
23624        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23625        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23626        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23627        std::fs::remove_dir_all(root).unwrap();
23628    }
23629
23630    #[test]
23631    fn session_task_capability_strips_snapshots_events_and_replies() {
23632        let mut bound = record("claude", "sr-task-projection");
23633        bound.updated_at_unix_ms = 2;
23634        bound.task_binding = Some(SessionTaskBindingV1 {
23635            revision: 1,
23636            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23637            changed_at_unix_ms: 2,
23638        });
23639        let request = NodeRequest::SetSessionTask {
23640            record_id: bound.record_id.clone(),
23641            expected_revision: 1,
23642            target: SessionTaskTargetV1::Clear,
23643        };
23644        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23645            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23646        }));
23647        assert!(!request_uses_unnegotiated_capability(
23648            &request,
23649            &baseline_capabilities().unwrap(),
23650        ));
23651        assert!(request_uses_unnegotiated_capability(&request, &[]));
23652
23653        let mut snapshot = terminal_test_shared().snapshot();
23654        snapshot.session_records.push(bound.clone());
23655        let mut reply = ResponseEnvelope {
23656            request_id: 1,
23657            result: Ok(NodeResponse::Resync {
23658                event_sequence: 1,
23659                oldest_available_sequence: 1,
23660                snapshot,
23661                events: vec![NodeEventEnvelope {
23662                    sequence: 1,
23663                    event: NodeEvent::SessionRecordUpserted {
23664                        record: bound.clone(),
23665                    },
23666                }],
23667            }),
23668        };
23669        project_response_without_session_task_binding(&mut reply);
23670        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23671            panic!("task projection changed the response kind");
23672        };
23673        assert!(snapshot.session_records[0].task_binding.is_none());
23674        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23675            panic!("task projection changed the event kind");
23676        };
23677        assert!(record.task_binding.is_none());
23678
23679        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23680            sequence: 2,
23681            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23682        });
23683        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23684            panic!("task event projection changed the event kind");
23685        };
23686        assert!(record.task_binding.is_none());
23687
23688        let mut direct = ResponseEnvelope {
23689            request_id: 2,
23690            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23691        };
23692        project_response_without_session_task_binding(&mut direct);
23693        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23694            panic!("task reply projection changed the response kind");
23695        };
23696        assert!(record.task_binding.is_none());
23697    }
23698
23699    #[test]
23700    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23701        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23702        let workspace = WorkspaceConfig::new(
23703            WorkspaceId::new("primary").unwrap(),
23704            std::env::current_dir().unwrap(),
23705        )
23706        .unwrap();
23707        let config = NodeServerConfig::new(
23708            DEFAULT_NODE_ENDPOINT,
23709            "fixture-token",
23710            NodeId::new("node-1").unwrap(),
23711            [workspace],
23712        )
23713        .unwrap();
23714        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23715    }
23716
23717    #[cfg(all(feature = "fixture", windows))]
23718    #[test]
23719    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23720        let root = temporary_workspace_root("provider-bundle-argv-hold");
23721        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23722        std::fs::create_dir_all(&root).unwrap();
23723        std::fs::create_dir_all(&outside).unwrap();
23724        let proof_path = root.join("provider.proof");
23725        let release_signal = root.join("release.signal");
23726        let config = NodeServerConfig::new(
23727            format!(
23728                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23729                std::process::id(),
23730            ),
23731            "fixture-token",
23732            NodeId::new("provider-bundle-hold-node").unwrap(),
23733            [WorkspaceConfig::new(
23734                WorkspaceId::new("primary").unwrap(),
23735                std::env::current_dir().unwrap(),
23736            )
23737            .unwrap()],
23738        )
23739        .unwrap()
23740        .with_spawn_profiles(SpawnProfileRegistry::new([
23741            crate::protocol::SpawnProfileDefaults {
23742                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23743                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23744                    .unwrap(),
23745                provider: agent("kimi"),
23746                mode: SessionMode::Pty,
23747                terminal_size: gate4agent_types::TerminalSize {
23748                    rows: 24,
23749                    columns: 80,
23750                },
23751                prompt: None,
23752                bundle_id: None,
23753                context_id: None,
23754                environment_profile_id: None,
23755            },
23756        ])
23757        .unwrap());
23758        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23759            config,
23760            agent("kimi"),
23761            proof_path.clone(),
23762            release_signal.clone(),
23763        )
23764        .unwrap();
23765        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23766
23767        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23768            agent("kimi"),
23769            proof_path.clone(),
23770            Some(release_signal.clone()),
23771        )
23772        .unwrap();
23773        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23774        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23775        let release_wait = wrapper
23776            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23777            .unwrap();
23778        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23779        assert!(proof_write < release_wait);
23780        assert!(release_wait < fixture_ready);
23781        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23782        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23783        assert_eq!(
23784            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23785            &[
23786                proof_path.to_string_lossy().into_owned(),
23787                release_signal.to_string_lossy().into_owned(),
23788            ],
23789        );
23790
23791        let error = NodeServer::provider_bundle_argv_fixture_spec(
23792            agent("kimi"),
23793            proof_path,
23794            Some(outside.join("release.signal")),
23795        )
23796        .unwrap_err();
23797        assert!(matches!(
23798            error,
23799            NodeServerError::Registry(message)
23800                if message == "provider bundle argv release path must share the proof parent"
23801        ));
23802        drop(server);
23803        std::fs::remove_dir_all(root).unwrap();
23804        std::fs::remove_dir_all(outside).unwrap();
23805    }
23806
23807    #[cfg(feature = "fixture")]
23808    #[test]
23809    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23810        let root = temporary_workspace_root("context-fixture-registry");
23811        std::fs::create_dir_all(&root).unwrap();
23812        let proof_path = root.join("context.proof");
23813        let config = NodeServerConfig::new(
23814            format!(
23815                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23816                std::process::id(),
23817            ),
23818            "fixture-token",
23819            NodeId::new("context-fixture-node").unwrap(),
23820            [WorkspaceConfig::new(
23821                WorkspaceId::new("primary").unwrap(),
23822                std::env::current_dir().unwrap(),
23823            )
23824            .unwrap()],
23825        )
23826        .unwrap();
23827        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23828        let mut enabled = server
23829            .shared
23830            .enabled_providers
23831            .iter()
23832            .map(AgentId::as_str)
23833            .collect::<Vec<_>>();
23834        enabled.sort_unstable();
23835        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23836        for provider in &server.shared.enabled_providers {
23837            assert!(server
23838                .shared
23839                .provider_adapter_contracts
23840                .iter()
23841                .any(|contract| {
23842                    &contract.provider == provider
23843                        && contract.family == AdapterFamily::History
23844                }));
23845        }
23846        drop(server);
23847        std::fs::remove_dir_all(root).unwrap();
23848    }
23849
23850    #[cfg(feature = "fixture")]
23851    #[test]
23852    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23853        let root = temporary_workspace_root("monitoring-context-fixture");
23854        std::fs::create_dir_all(&root).unwrap();
23855        let proof_path = root.join("context.proof");
23856        let standard = NodeServer::context_pack_fixture_catalog(
23857            proof_path.clone(),
23858            None,
23859            false,
23860        ).unwrap();
23861        let monitoring = NodeServer::context_pack_fixture_catalog(
23862            proof_path.clone(),
23863            None,
23864            true,
23865        ).unwrap();
23866        let claude = AgentId::new("claude").unwrap();
23867        let standard_claude = standard.get(&claude).unwrap();
23868        let monitoring_claude = monitoring.get(&claude).unwrap();
23869        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23870        // standard nor the monitoring fixture declares a hook adapter
23871        // anymore.
23872        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23873        assert_eq!(
23874            monitoring_claude.capabilities.adapters.history,
23875            standard_claude.capabilities.adapters.history,
23876        );
23877        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23878        let codex = AgentId::new("codex").unwrap();
23879        let standard_codex = standard.get(&codex).unwrap();
23880        let monitoring_codex = monitoring.get(&codex).unwrap();
23881        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23882        assert_eq!(
23883            monitoring_codex.capabilities.adapters.history,
23884            standard_codex.capabilities.adapters.history,
23885        );
23886        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23887        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23888            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23889            .unwrap();
23890        for required in [
23891            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23892            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23893            "@('role', 'text')",
23894            "g4a-private-provider-session-canary",
23895            "private-provider-login-identity",
23896            "$rawProof",
23897            "$env:GATE4AGENT_HOOK_URL",
23898            "g4a-private-provider-session-canary",
23899        ] {
23900            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23901        }
23902        for provider in ["grok", "kimi"] {
23903            let provider = AgentId::new(provider).unwrap();
23904            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23905        }
23906
23907        let config = NodeServerConfig::new(
23908            format!(
23909                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23910                std::process::id(),
23911            ),
23912            "fixture-token",
23913            NodeId::new("monitoring-context-fixture-node").unwrap(),
23914            [WorkspaceConfig::new(
23915                WorkspaceId::new("primary").unwrap(),
23916                std::env::current_dir().unwrap(),
23917            )
23918            .unwrap()],
23919        )
23920        .unwrap();
23921        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23922        assert!(server.shared.fixture_semantic_hook_policy);
23923        drop(server);
23924        std::fs::remove_dir_all(root).unwrap();
23925    }
23926
23927    #[cfg(feature = "fixture")]
23928    #[test]
23929    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23930        let root = temporary_workspace_root("kimi-context-only-fixture");
23931        std::fs::create_dir_all(&root).unwrap();
23932        let proof_path = root.join("context.proof");
23933        let standard = NodeServer::context_pack_fixture_catalog(
23934            proof_path.clone(),
23935            None,
23936            false,
23937        ).unwrap();
23938        let kimi = AgentId::new("kimi").unwrap();
23939        let context_only = NodeServer::context_pack_fixture_catalog(
23940            proof_path,
23941            Some(&kimi),
23942            false,
23943        )
23944        .unwrap();
23945
23946        let codex = AgentId::new("codex").unwrap();
23947        assert_eq!(
23948            standard.get(&codex).unwrap().launch.fixed_args,
23949            context_only.get(&codex).unwrap().launch.fixed_args,
23950        );
23951        let script = context_only
23952            .get(&kimi)
23953            .unwrap()
23954            .launch
23955            .fixed_args
23956            .iter()
23957            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23958            .expect("Kimi context-only validation script is missing");
23959        for required in [
23960            "$bundleArgs.Count -ne 0",
23961            "GATE4AGENT_CONTEXT_ROOT",
23962            "context-pack.json",
23963            "$entries.Count -ne 1",
23964            "g4a-context-pack-v1",
23965            "$document.PSObject.Properties['cwd']",
23966            "'claude', 'codex', 'grok', 'kimi'",
23967            "$hasUser",
23968            "$hasAssistant",
23969            "SHA256",
23970            "context-only",
23971        ] {
23972            assert!(script.contains(required), "missing validation: {required}");
23973        }
23974        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23975            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23976        }
23977        assert!(!standard
23978            .get(&kimi)
23979            .unwrap()
23980            .launch
23981            .fixed_args
23982            .iter()
23983            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23984
23985        std::fs::remove_dir_all(root).unwrap();
23986    }
23987
23988    #[cfg(all(feature = "fixture", windows))]
23989    #[tokio::test]
23990    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23991        let root = temporary_workspace_root("kimi-context-only-spawn");
23992        let workspace_root = root.join("workspace");
23993        std::fs::create_dir_all(&workspace_root).unwrap();
23994        let materialization_root = root.join("private-materializations");
23995        let proof_path = root.join("context.proof");
23996        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23997        let workspace_id = WorkspaceId::new("primary").unwrap();
23998        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23999        let endpoint = format!(
24000            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24001            std::process::id(),
24002            unix_time_ms(),
24003        );
24004        let spawn_profiles = SpawnProfileRegistry::new([
24005            crate::protocol::SpawnProfileDefaults {
24006                profile_id: profile_id.clone(),
24007                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24008                provider: agent("kimi"),
24009                mode: SessionMode::Pty,
24010                terminal_size: gate4agent_types::TerminalSize {
24011                    rows: 30,
24012                    columns: 120,
24013                },
24014                prompt: None,
24015                bundle_id: None,
24016                context_id: None,
24017                environment_profile_id: None,
24018            },
24019        ])
24020        .unwrap();
24021        let mut config = NodeServerConfig::new(
24022            &endpoint,
24023            "fixture-token",
24024            node_id.clone(),
24025            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24026        )
24027        .unwrap()
24028        .with_spawn_profiles(spawn_profiles)
24029        .with_session_environment_materialization(
24030            materialization_root,
24031            Arc::new(FixtureSecretResolver {
24032                deny: Arc::new(AtomicBool::new(false)),
24033            }),
24034        )
24035        .unwrap();
24036        config.fixture_raw_pty_runtime = true;
24037        let server = NodeServer::new_context_only_proof_fixture(
24038            config,
24039            agent("kimi"),
24040            proof_path.clone(),
24041        )
24042        .unwrap();
24043        let shared = Arc::clone(&server.shared);
24044        let shutdown = server.shutdown_handle();
24045
24046        let history = HistorySessionRecord {
24047            session_id: "context-only-source-history".to_owned(),
24048            title: None,
24049            cwd: None,
24050            model: None,
24051            message_count: 2,
24052            completed_turn_count: None,
24053            total_tokens: 0,
24054            messages: vec![
24055                gate4agent_types::HistoryMessageRecord {
24056                    role: gate4agent_types::HistoryMessageRole::User,
24057                    text: "continue the bounded parent run through native C2".to_owned(),
24058                },
24059                gate4agent_types::HistoryMessageRecord {
24060                    role: gate4agent_types::HistoryMessageRole::Assistant,
24061                    text: "the exact parent context is ready for the child run".to_owned(),
24062                },
24063            ],
24064        };
24065        let pack = NodeContextPack::export(
24066            ContextPackLineageReceipt {
24067                source_node_id: node_id.clone(),
24068                source_session: SessionAddress {
24069                    workspace_id: workspace_id.clone(),
24070                    session: SessionKey {
24071                        instance_id: AgentInstanceId(71),
24072                        generation: SessionGeneration(1),
24073                    },
24074                },
24075                source_provider: agent("codex"),
24076            },
24077            &history,
24078        )
24079        .unwrap();
24080        let expected_context_hash = digest(&SHA256, pack.bytes())
24081            .as_ref()
24082            .iter()
24083            .map(|byte| format!("{byte:02x}"))
24084            .collect::<String>();
24085        let context = shared
24086            .context_catalog
24087            .write()
24088            .unwrap_or_else(|poisoned| poisoned.into_inner())
24089            .insert(pack)
24090            .unwrap();
24091        let spec = SpawnSpec {
24092            target: crate::protocol::SpawnTarget {
24093                node_id,
24094                workspace_id: workspace_id.clone(),
24095                worktree_id: None,
24096            },
24097            profile_id,
24098            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24099                "target-kimi-r1",
24100            ).unwrap(),
24101            overrides: crate::protocol::SpawnOverrides {
24102                context_id: crate::protocol::SpawnOverride::Set {
24103                    value: context.id.clone(),
24104                },
24105                ..crate::protocol::SpawnOverrides::default()
24106            },
24107            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24108            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24109            required_capabilities: SpawnRequiredCapabilities::default(),
24110        };
24111        let resolved = match shared.resolve_spawn_spec(&spec) {
24112            Ok(resolved) => resolved,
24113            Err(error) => panic!(
24114                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24115                error.code,
24116                error.message,
24117            ),
24118        };
24119        let resolved_context = match shared.resolve_context(&resolved) {
24120            Ok(Some(resolved_context)) => resolved_context,
24121            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24122            Err(error) => panic!(
24123                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24124                error.code,
24125                error.message,
24126            ),
24127        };
24128        assert_eq!(resolved_context, context);
24129        let runtime_policy = match shared
24130            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24131            .await
24132        {
24133            Ok(runtime_policy) => runtime_policy,
24134            Err(error) => panic!(
24135                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24136                error.code,
24137                error.message,
24138            ),
24139        };
24140        assert!(runtime_policy.raw_pty_lifecycle);
24141
24142        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24143        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24144            agent("kimi"),
24145            TransportKind::Pty,
24146            vec![EnvMutation {
24147                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24148                value: Some(OsString::from("must-remain-rejected")),
24149            }],
24150        )
24151        .unwrap();
24152        assert_eq!(
24153            launch_control
24154                .install_native_launch_environment_overlay(
24155                    AgentInstanceId(72),
24156                    generic_overlay,
24157                )
24158                .unwrap_err(),
24159            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24160        );
24161
24162        let diagnostic_address = SessionAddress {
24163            workspace_id: workspace_id.clone(),
24164            session: SessionKey {
24165                instance_id: AgentInstanceId(73),
24166                generation: SessionGeneration(1),
24167            },
24168        };
24169        let (diagnostic_overlay, diagnostic_materialization) = match shared
24170            .prepare_session_materialization(
24171                &diagnostic_address,
24172                &agent("kimi"),
24173                SessionMode::Pty,
24174                None,
24175                None,
24176                Some(&resolved_context),
24177                None,
24178            )
24179        {
24180            Ok(Some(prepared)) => prepared,
24181            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24182            Err(error) => panic!(
24183                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24184                error.code,
24185                error.message,
24186            ),
24187        };
24188        let diagnostic_overlay = diagnostic_overlay
24189            .expect("Kimi context-only materialization omitted its environment overlay");
24190        assert!(matches!(
24191            &diagnostic_overlay,
24192            PreparedNativeLaunchOverlay::Environment(_),
24193        ));
24194        match shared.install_prepared_launch_overlay(
24195            diagnostic_address.session.instance_id,
24196            diagnostic_overlay,
24197        ) {
24198            Ok(None) => {}
24199            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24200            Err(error) => panic!(
24201                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24202                error.code,
24203                error.message,
24204            ),
24205        }
24206        assert!(launch_control.clear_native_instance_launch_overlay(
24207            diagnostic_address.session.instance_id,
24208        ));
24209        drop(diagnostic_materialization);
24210        assert!(shared
24211            .materializations
24212            .lock()
24213            .unwrap_or_else(|poisoned| poisoned.into_inner())
24214            .is_empty());
24215
24216        let node_task = tokio::spawn(server.run());
24217        let ready_client = timeout(Duration::from_secs(5), async {
24218            loop {
24219                match gate4agent_node_wire::LocalNodeClient::connect(
24220                    &endpoint,
24221                    &shared.node_id,
24222                    ClientRole::Observer,
24223                    "fixture-token",
24224                )
24225                .await
24226                {
24227                    Ok(client) => break client,
24228                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24229                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24230                }
24231            }
24232        })
24233        .await
24234        .expect("Kimi context-only Node did not become ready");
24235        assert_eq!(
24236            ready_client
24237                .hello()
24238                .snapshot
24239                .provider_runtime_statuses
24240                .iter()
24241                .find(|status| status.provider().as_str() == "kimi")
24242                .expect("Kimi runtime status is missing")
24243                .mode(),
24244            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24245        );
24246        drop(ready_client);
24247
24248        let receipt = match shared.spawn_from_spec(spec).await {
24249            Ok(receipt) => receipt,
24250            Err(error) => panic!(
24251                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24252                error.code,
24253                error.message,
24254            ),
24255        };
24256        assert_eq!(receipt.provider, agent("kimi"));
24257        assert_eq!(receipt.context.as_ref(), Some(&context));
24258        assert!(receipt.context_binding_is_valid());
24259        timeout(Duration::from_secs(5), async {
24260            while !proof_path.is_file() {
24261                sleep(Duration::from_millis(10)).await;
24262            }
24263        })
24264        .await
24265        .expect("Kimi context-only fixture did not write its proof");
24266
24267        let proof = std::fs::read_to_string(&proof_path).unwrap();
24268        let lines = proof.lines().collect::<Vec<_>>();
24269        assert_eq!(lines.len(), 7);
24270        assert_eq!(lines[0], "context-only");
24271        assert!(Path::new(lines[1]).is_absolute());
24272        assert_ne!(
24273            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24274            std::fs::canonicalize(&workspace_root).unwrap(),
24275        );
24276        assert_eq!(
24277            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24278            std::fs::canonicalize(&workspace_root).unwrap(),
24279        );
24280        assert_eq!(lines[3], expected_context_hash);
24281        assert_eq!(lines[4], "g4a-context-pack-v1");
24282        assert_eq!(lines[5], "codex");
24283        assert_eq!(lines[6], "2");
24284        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24285            session.instance_id == receipt.session.session.instance_id
24286                && session.agent_id.as_str() == "kimi"
24287        }));
24288
24289        shutdown.request_shutdown().await.unwrap();
24290        timeout(Duration::from_secs(5), node_task)
24291            .await
24292            .unwrap()
24293            .unwrap()
24294            .unwrap();
24295        drop(shutdown);
24296        drop(shared);
24297        std::fs::remove_dir_all(root).unwrap();
24298    }
24299
24300    #[cfg(feature = "fixture")]
24301    #[test]
24302    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24303        let root = temporary_workspace_root("clean-exit-constructor");
24304        std::fs::create_dir_all(&root).unwrap();
24305        let config = construction_test_config("clean-exit-constructor")
24306            .with_state_path(root.join("node-state.json"))
24307            .unwrap();
24308        let server = NodeServer::new_clean_exit_fixture(
24309            config,
24310            root.clone(),
24311            root.join("started.marker"),
24312            root.join("release.signal"),
24313        )
24314        .unwrap();
24315        assert_eq!(
24316            server
24317                .shared
24318                .enabled_providers
24319                .iter()
24320                .map(AgentId::as_str)
24321                .collect::<Vec<_>>(),
24322            ["claude"],
24323        );
24324        drop(server);
24325        std::fs::remove_dir_all(root).unwrap();
24326    }
24327
24328    #[test]
24329    fn production_registry_provider_contract_manifest_is_exact() {
24330        let registry = active_registry().unwrap();
24331        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24332        assert_eq!(
24333            providers
24334                .iter()
24335                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24336                .collect::<Vec<_>>(),
24337            vec![
24338                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24339                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24340                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24341                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24342            ]
24343        );
24344        assert_eq!(
24345            adapters
24346                .iter()
24347                .map(|contract| (
24348                    contract.provider.as_str(),
24349                    contract.family,
24350                    contract.adapter_id.as_str(),
24351                    contract.revision.as_str(),
24352                ))
24353                .collect::<Vec<_>>(),
24354            vec![
24355                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24356                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24357                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24358                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24359                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24360                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24361                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24362                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24363                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24364                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24365                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24366                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24367                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24368                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24369                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24370                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24371                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24372                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24373                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24374                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24375                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24376                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24377                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24378            ]
24379        );
24380    }
24381
24382    fn construction_test_config(name: &str) -> NodeServerConfig {
24383        let workspace = WorkspaceConfig::new(
24384            WorkspaceId::new("primary").unwrap(),
24385            std::env::current_dir().unwrap(),
24386        )
24387        .unwrap();
24388        NodeServerConfig::new(
24389            format!(r"\\.\pipe\gate4agent-{name}"),
24390            "fixture-token",
24391            NodeId::new(name).unwrap(),
24392            [workspace],
24393        )
24394        .unwrap()
24395    }
24396
24397    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24398        let adapter = AdapterBinding::new(
24399            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24400            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24401            gate4agent_types::AdapterVerification::SyntheticFixture,
24402        )
24403        .unwrap();
24404        let agent_ids = ["claude", "codex", "kimi"]
24405            .into_iter()
24406            .map(|agent_id| AgentId::new(agent_id).unwrap())
24407            .collect::<Vec<_>>();
24408        let adapters = gate4agent_catalog::AdapterRegistry::new(
24409            [
24410                AdapterFamily::PtySemantic,
24411                AdapterFamily::Pipe,
24412                AdapterFamily::OneShot,
24413                AdapterFamily::Acp,
24414                AdapterFamily::Hook,
24415                AdapterFamily::ManagedHook,
24416                AdapterFamily::History,
24417                AdapterFamily::Resume,
24418                AdapterFamily::SessionOptions,
24419                AdapterFamily::CapabilityProbe,
24420            ]
24421            .into_iter()
24422            .map(|family| gate4agent_catalog::AdapterDescriptor {
24423                family,
24424                binding: adapter.clone(),
24425                agents: agent_ids.clone(),
24426            }),
24427        )
24428        .unwrap();
24429        let specs = agent_ids
24430            .iter()
24431            .map(|agent_id| {
24432                let agent_id = agent_id.as_str();
24433                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24434                spec.revision =
24435                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24436                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24437                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24438                    adapter: adapter.clone(),
24439                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24440                    launch_override: None,
24441                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24442                });
24443                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24444                    adapter: adapter.clone(),
24445                    launch_override: None,
24446                });
24447                spec.capabilities.adapters.hook = Some(adapter.clone());
24448                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24449                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24450                spec.capabilities.adapters.history = Some(adapter.clone());
24451                spec.capabilities.adapters.resume = Some(adapter.clone());
24452                spec.capabilities.adapters.session_options = Some(adapter.clone());
24453                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24454                spec
24455            })
24456            .collect::<Vec<_>>();
24457        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24458    }
24459
24460    /// The real fleet's manifest is accepted by a real server construction.
24461    ///
24462    /// The count is asserted against `active_registry` itself rather than a
24463    /// literal, so this stays a test that construction succeeds for whatever
24464    /// the fleet is -- which is the property worth guarding -- instead of a
24465    /// second place the fleet size is written down and has to be kept in
24466    /// step with the first.
24467    #[test]
24468    fn node_server_construction_accepts_the_production_manifest() {
24469        let registry = active_registry().unwrap();
24470        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24471        assert_eq!(providers.len(), registry.iter().count());
24472        assert!(!providers.is_empty());
24473        let server = NodeServer::new_with_registry(
24474            construction_test_config("manifest-production-bound"),
24475            registry,
24476        )
24477        .unwrap();
24478        drop(server);
24479    }
24480
24481    #[test]
24482    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24483        let registry = maximum_size_provider_manifest_registry();
24484        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24485        assert_eq!(providers.len(), 3);
24486        assert_eq!(adapters.len(), 30);
24487        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24488        let error = match NodeServer::new_with_registry(
24489            construction_test_config("manifest-handshake-overflow"),
24490            registry,
24491        ) {
24492            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24493            Err(error) => error,
24494        };
24495        assert!(matches!(
24496            error,
24497            NodeServerError::ProviderContractManifest(message)
24498                if message.contains("authentication binding length")
24499                    && message.contains("8192-byte limit")
24500        ));
24501    }
24502
24503    #[test]
24504    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24505        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24506        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24507        let registry = AgentRegistry::new([spec]).unwrap();
24508        assert!(matches!(
24509            provider_contract_manifest(&registry),
24510            Err(NodeServerError::ProviderContractManifest(message))
24511                if message.contains("exceeds the 128-byte limit")
24512        ));
24513    }
24514
24515    #[test]
24516    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24517        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24518        spec.revision = "invalid revision".to_owned();
24519        let registry = AgentRegistry::new([spec]).unwrap();
24520        assert!(matches!(
24521            provider_contract_manifest(&registry),
24522            Err(NodeServerError::ProviderContractManifest(message))
24523                if message.contains("bounded lowercase ASCII")
24524        ));
24525    }
24526
24527    #[test]
24528    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24529        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24530        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24531        let conflicting_binding = builtin_registry()
24532            .get_by_id("codex")
24533            .unwrap()
24534            .capabilities
24535            .adapters
24536            .one_shot
24537            .clone()
24538            .unwrap();
24539        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24540        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24541        pipe.adapter = transport_binding;
24542        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24543        let registry = AgentRegistry::new([spec]).unwrap();
24544        assert!(matches!(
24545            provider_contract_manifest(&registry),
24546            Err(NodeServerError::ProviderContractManifest(message))
24547                if message.contains("conflicting OneShot adapter bindings")
24548        ));
24549    }
24550
24551    #[test]
24552    fn public_node_failures_expose_only_fixed_categories() {
24553        let provider = failure(
24554            NodeFailureCode::BackendOperationFailed,
24555            r"provider bearer-secret failed at C:\private\session.jsonl",
24556        );
24557        assert_eq!(provider.message, "backend-operation-failed");
24558        assert!(!provider.message.contains("bearer-secret"));
24559        assert!(!provider.message.contains(r"C:\private"));
24560        let persistence = persistence_failure(io::Error::new(
24561            io::ErrorKind::Other,
24562            r"state write failed at C:\private\state-v1.json",
24563        ));
24564        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24565        assert!(!persistence.message.contains(r"C:\private"));
24566
24567        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24568            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24569            assert_eq!(
24570                generic.to_string(),
24571                "node durable state failed: durable-state-load-failed",
24572            );
24573        }
24574    }
24575
24576    #[test]
24577    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24578        let sequence = b"\x1b[1;5D".to_vec();
24579        assert_eq!(
24580            terminal_bytes_action(sequence.clone()).unwrap(),
24581            InputAction::TerminalBytes(sequence),
24582        );
24583        assert_eq!(
24584            terminal_bytes_action(Vec::new()).unwrap_err().code,
24585            NodeFailureCode::InvalidRequest,
24586        );
24587        assert_eq!(
24588            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24589                .unwrap_err()
24590                .code,
24591            NodeFailureCode::InvalidRequest,
24592        );
24593    }
24594
24595    #[test]
24596    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24597        let root = temporary_workspace_root("exclusive-state-owner");
24598        std::fs::create_dir_all(&root).unwrap();
24599        let workspace = WorkspaceConfig::new(
24600            WorkspaceId::new("primary").unwrap(),
24601            &root,
24602        )
24603        .unwrap();
24604        let state_path = root.join("state-v1.json");
24605        let endpoint = format!(
24606            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24607            std::process::id(),
24608            std::time::SystemTime::now()
24609                .duration_since(std::time::UNIX_EPOCH)
24610                .unwrap()
24611                .as_nanos(),
24612        );
24613        let config = NodeServerConfig::new(
24614            endpoint,
24615            "fixture-token",
24616            NodeId::new("node-1").unwrap(),
24617            [workspace],
24618        )
24619        .unwrap()
24620        .with_state_path(&state_path)
24621        .unwrap();
24622
24623        let first = NodeServer::new(config.clone()).unwrap();
24624        let error = match NodeServer::new(config.clone()) {
24625            Ok(_) => panic!("a second node server acquired the same durable state path"),
24626            Err(error) => error,
24627        };
24628        assert!(matches!(
24629            &error,
24630            NodeServerError::DurableState(error)
24631                if error.kind() == io::ErrorKind::WouldBlock
24632        ));
24633        assert_eq!(
24634            error.to_string(),
24635            "node durable state failed: durable-state-lock-failed",
24636        );
24637        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24638        drop(first);
24639        let second = NodeServer::new(config).unwrap();
24640        drop(second);
24641        std::fs::remove_dir_all(root).unwrap();
24642    }
24643
24644    #[test]
24645    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24646        let catalog = active_registry().unwrap();
24647        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24648        let workspace_id = WorkspaceId::new("primary").unwrap();
24649        let workspace = WorkspaceConfig::new(
24650            workspace_id.clone(),
24651            std::env::current_dir().unwrap(),
24652        )
24653        .unwrap();
24654        let shared = NodeShared::new(
24655            handle,
24656            "fixture-token".to_owned(),
24657            NodeId::new("node-1").unwrap(),
24658            vec![workspace.clone()],
24659            vec![agent("claude"), agent("codex"), agent("kimi")],
24660        );
24661        let instance_id = AgentInstanceId(17);
24662        let generation = SessionGeneration(3);
24663        assert!(shared.address_for(instance_id, generation).is_none());
24664        shared.bind_session(instance_id, workspace_id.clone(), generation);
24665        let address = SessionAddress {
24666            workspace_id,
24667            session: SessionKey { instance_id, generation },
24668        };
24669        assert_eq!(
24670            shared.workspace_root(&address.workspace_id).unwrap(),
24671            workspace.canonical_root,
24672        );
24673        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24674        shared.remove_binding(&address);
24675        assert!(shared.address_for(instance_id, generation).is_none());
24676    }
24677
24678    #[test]
24679    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24680        let address = SessionAddress {
24681            workspace_id: WorkspaceId::new("primary").unwrap(),
24682            session: SessionKey {
24683                instance_id: AgentInstanceId(7),
24684                generation: SessionGeneration(2),
24685            },
24686        };
24687        let record_id = SessionRecordId::new("record-a").unwrap();
24688        let size = gate4agent_types::TerminalSize {
24689            rows: 24,
24690            columns: 80,
24691        };
24692        let address_requests = [
24693            NodeRequest::Resume {
24694                session: address.clone(),
24695                terminal_size: size,
24696                initial_prompt: None,
24697            },
24698            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24699            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24700            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24701            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24702            NodeRequest::TerminalControl {
24703                session: address.clone(),
24704                control: TerminalControl::Enter,
24705            },
24706            NodeRequest::Resize { session: address.clone(), size },
24707            NodeRequest::Interrupt { session: address.clone() },
24708            NodeRequest::Stop { session: address.clone(), force: false },
24709            NodeRequest::Remove { session: address },
24710        ];
24711        for request in &address_requests {
24712            assert!(request_requires_open_provider_ids_with(
24713                request,
24714                |_| Some(agent("third-party-agent")),
24715                |_| Some(agent("claude")),
24716            ));
24717            assert!(!request_requires_open_provider_ids_with(
24718                request,
24719                |_| Some(agent("claude")),
24720                |_| Some(agent("claude")),
24721            ));
24722            assert!(request_requires_open_provider_ids_with(
24723                request,
24724                |_| None,
24725                |_| Some(agent("claude")),
24726            ));
24727        }
24728
24729        let record_requests = [
24730            NodeRequest::RenameSessionRecord {
24731                record_id: record_id.clone(),
24732                display_name: "renamed".to_owned(),
24733            },
24734            NodeRequest::ResumeSessionRecord {
24735                record_id: record_id.clone(),
24736                terminal_size: size,
24737                initial_prompt: None,
24738            },
24739            NodeRequest::ForgetSessionRecord { record_id },
24740        ];
24741        for request in &record_requests {
24742            assert!(request_requires_open_provider_ids_with(
24743                request,
24744                |_| Some(agent("claude")),
24745                |_| Some(agent("grok")),
24746            ));
24747            assert!(!request_requires_open_provider_ids_with(
24748                request,
24749                |_| Some(agent("claude")),
24750                |_| Some(agent("kimi")),
24751            ));
24752            assert!(request_requires_open_provider_ids_with(
24753                request,
24754                |_| Some(agent("claude")),
24755                |_| None,
24756            ));
24757        }
24758
24759        assert!(request_requires_open_provider_ids_with(
24760            &NodeRequest::Spawn {
24761                workspace_id: WorkspaceId::new("primary").unwrap(),
24762                provider: agent("third-party-agent"),
24763                mode: SessionMode::Pty,
24764                terminal_size: size,
24765                initial_prompt: None,
24766            },
24767            |_| None,
24768            |_| None,
24769        ));
24770        assert!(!request_requires_open_provider_ids_with(
24771            &NodeRequest::Snapshot,
24772            |_| None,
24773            |_| None,
24774        ));
24775        assert!(request_requires_open_provider_ids_with(
24776            &NodeRequest::ForgetContextPack {
24777                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24778            },
24779            |_| None,
24780            |_| None,
24781        ));
24782    }
24783
24784    #[test]
24785    fn legacy_projection_filters_open_record_replies_and_event_history() {
24786        let catalog = active_registry().unwrap();
24787        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24788        let workspace = WorkspaceConfig::new(
24789            WorkspaceId::new("primary").unwrap(),
24790            std::env::current_dir().unwrap(),
24791        )
24792        .unwrap();
24793        let shared = NodeShared::new(
24794            handle,
24795            "fixture-token".to_owned(),
24796            NodeId::new("node-1").unwrap(),
24797            vec![workspace],
24798            vec![agent("claude"), agent("grok")],
24799        );
24800        let legacy = record("claude", "legacy-record");
24801        let open = record("grok", "open-record");
24802        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24803            record: legacy.clone(),
24804        });
24805        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24806            record: open.clone(),
24807        });
24808        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24809            record_id: legacy.record_id.clone(),
24810        });
24811        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24812            record_id: open.record_id.clone(),
24813        });
24814        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24815        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24816        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24817        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24818
24819        let mut record_reply = ResponseEnvelope {
24820            request_id: 1,
24821            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24822        };
24823        project_response_legacy_provider_ids(&shared, &mut record_reply);
24824        assert!(matches!(
24825            record_reply.result,
24826            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24827        ));
24828
24829        let mut resync = ResponseEnvelope {
24830            request_id: 2,
24831            result: Ok(NodeResponse::Resync {
24832                event_sequence: open_removed.sequence,
24833                oldest_available_sequence: 1,
24834                snapshot: shared.snapshot(),
24835                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24836            }),
24837        };
24838        project_response_legacy_provider_ids(&shared, &mut resync);
24839        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24840            panic!("legacy projection changed the resync response kind");
24841        };
24842        assert!(snapshot.session_records.iter().all(|item| {
24843            provider_id_is_legacy(&item.provider)
24844        }));
24845        assert_eq!(events.len(), 2);
24846        assert!(events.iter().all(|event| matches!(
24847            &event.event,
24848            NodeEvent::SessionRecordUpserted { record }
24849                if provider_id_is_legacy(&record.provider)
24850        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24851    }
24852
24853    #[test]
24854    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24855        let catalog = active_registry().unwrap();
24856        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24857        let workspace_id = WorkspaceId::new("primary").unwrap();
24858        let workspace = WorkspaceConfig::new(
24859            workspace_id.clone(),
24860            std::env::current_dir().unwrap(),
24861        )
24862        .unwrap();
24863        let shared = NodeShared::new(
24864            handle,
24865            "fixture-token".to_owned(),
24866            NodeId::new("node-1").unwrap(),
24867            vec![workspace],
24868            vec![agent("claude")],
24869        );
24870        for index in 0..CONTROL_SESSIONS_MAX {
24871            shared.bind_session(
24872                AgentInstanceId((index + 1) as u64),
24873                workspace_id.clone(),
24874                SessionGeneration(1),
24875            );
24876        }
24877        let error = shared.ensure_binding_capacity().unwrap_err();
24878        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24879    }
24880
24881    #[tokio::test]
24882    async fn provider_spawn_admission_rejects_before_session_mutation() {
24883        let catalog = active_registry().unwrap();
24884        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24885        let workspace = WorkspaceConfig::new(
24886            WorkspaceId::new("primary").unwrap(),
24887            std::env::current_dir().unwrap(),
24888        )
24889        .unwrap();
24890        let mut shared = NodeShared::new(
24891            handle,
24892            "fixture-token".to_owned(),
24893            NodeId::new("node-provider-admission").unwrap(),
24894            vec![workspace],
24895            vec![agent("claude")],
24896        );
24897        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24898            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24899        ])
24900        .unwrap();
24901        let terminal_size = gate4agent_types::TerminalSize {
24902            rows: 24,
24903            columns: 80,
24904        };
24905
24906        let unavailable = shared
24907            .spawn_session(
24908                WorkspaceId::new("primary").unwrap(),
24909                agent("claude"),
24910                SessionMode::Pty,
24911                terminal_size,
24912                None,
24913            )
24914            .await
24915            .unwrap_err();
24916        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24917        assert_eq!(unavailable.message, "backend-operation-failed");
24918
24919        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24920            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24921                agent("claude"),
24922                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24923            ),
24924        ])
24925        .unwrap();
24926        let semantic = shared
24927            .spawn_session(
24928                WorkspaceId::new("primary").unwrap(),
24929                agent("claude"),
24930                SessionMode::Inline,
24931                terminal_size,
24932                None,
24933            )
24934            .await
24935            .unwrap_err();
24936        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24937        assert_eq!(semantic.message, "unsupported-capability");
24938
24939        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24940        assert!(shared
24941            .session_bindings
24942            .lock()
24943            .unwrap_or_else(|poisoned| poisoned.into_inner())
24944            .is_empty());
24945        assert!(shared
24946            .session_records
24947            .lock()
24948            .unwrap_or_else(|poisoned| poisoned.into_inner())
24949            .records
24950            .is_empty());
24951        assert!(shared.handle.snapshot().sessions.is_empty());
24952    }
24953
24954    #[tokio::test]
24955    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24956        let catalog = active_registry().unwrap();
24957        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24958        let workspace_id = WorkspaceId::new("primary").unwrap();
24959        let workspace = WorkspaceConfig::new(
24960            workspace_id.clone(),
24961            std::env::current_dir().unwrap(),
24962        )
24963        .unwrap();
24964        let shared = NodeShared::new(
24965            handle,
24966            "fixture-token".to_owned(),
24967            NodeId::new("node-raw-spawn-record").unwrap(),
24968            vec![workspace],
24969            vec![agent("claude")],
24970        );
24971        let address = SessionAddress {
24972            workspace_id,
24973            session: SessionKey {
24974                instance_id: AgentInstanceId(71),
24975                generation: SessionGeneration::default(),
24976            },
24977        };
24978
24979        let record_id = shared
24980            .bind_spawn_session(
24981                &address,
24982                agent("claude"),
24983                SessionMode::Pty,
24984                ProviderRuntimePolicy::raw_pty(),
24985                SpawnRecordPolicy::Always,
24986                None,
24987            )
24988            .unwrap()
24989            .unwrap();
24990        shared
24991            .handle
24992            .dispatch(shared.prepare_command(ControlCommand::Register {
24993                instance_id: address.session.instance_id,
24994                agent_id: agent("claude"),
24995                transport: TransportKind::Pty,
24996            }))
24997            .unwrap();
24998        runtime.tick().await;
24999
25000        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25001            session.instance_id == address.session.instance_id
25002                && session.generation == address.session.generation
25003        }));
25004        let record = shared.record(&record_id).unwrap();
25005        assert_eq!(record.display_name, "claude #1");
25006        assert_eq!(record.state, ManagedSessionState::Live);
25007        assert_eq!(record.active_session.as_ref(), Some(&address));
25008        assert!(record.provider_session.is_none());
25009        let binding_record_id = shared
25010            .session_bindings
25011            .lock()
25012            .unwrap_or_else(|poisoned| poisoned.into_inner())
25013            .get(&address.session.instance_id)
25014            .and_then(|binding| binding.record_id.clone());
25015        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25016
25017        let renamed = shared
25018            .rename_session_record(&record_id, "raw review".to_owned())
25019            .unwrap();
25020        assert_eq!(renamed.record_id, record_id);
25021        assert_eq!(renamed.display_name, "raw review");
25022        shared.reconcile_managed_record(
25023            &record_id,
25024            &address,
25025            &ControlEvent {
25026                sequence: 1,
25027                command_id: None,
25028                instance_id: address.session.instance_id,
25029                generation: address.session.generation,
25030                event: ControlEventKind::Running { process_id: None },
25031            },
25032            false,
25033            false,
25034        );
25035        let running = shared.record(&record_id).unwrap();
25036        assert_eq!(running.state, ManagedSessionState::Live);
25037        assert_eq!(running.active_session.as_ref(), Some(&address));
25038        assert_eq!(running.display_name, "raw review");
25039
25040        shared.reconcile_managed_record(
25041            &record_id,
25042            &address,
25043            &ControlEvent {
25044                sequence: 2,
25045                command_id: None,
25046                instance_id: address.session.instance_id,
25047                generation: address.session.generation,
25048                event: ControlEventKind::Exited {
25049                    exit_code: Some(0),
25050                    forced: false,
25051                },
25052            },
25053            false,
25054            false,
25055        );
25056        let detached = shared.record(&record_id).unwrap();
25057        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25058        assert!(detached.active_session.is_none());
25059        assert_eq!(detached.display_name, "raw review");
25060        let renamed_detached = shared
25061            .rename_session_record(&record_id, "raw review detached".to_owned())
25062            .unwrap();
25063        assert_eq!(renamed_detached.record_id, record_id);
25064        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25065        assert!(renamed_detached.active_session.is_none());
25066        assert_eq!(renamed_detached.display_name, "raw review detached");
25067        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25068    }
25069
25070    #[test]
25071    fn legacy_raw_spawn_binding_remains_untracked() {
25072        let catalog = active_registry().unwrap();
25073        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25074        let workspace_id = WorkspaceId::new("primary").unwrap();
25075        let workspace = WorkspaceConfig::new(
25076            workspace_id.clone(),
25077            std::env::current_dir().unwrap(),
25078        )
25079        .unwrap();
25080        let shared = NodeShared::new(
25081            handle,
25082            "fixture-token".to_owned(),
25083            NodeId::new("node-legacy-raw-spawn").unwrap(),
25084            vec![workspace],
25085            vec![agent("claude")],
25086        );
25087        let address = SessionAddress {
25088            workspace_id,
25089            session: SessionKey {
25090                instance_id: AgentInstanceId(72),
25091                generation: SessionGeneration::default(),
25092            },
25093        };
25094
25095        let record_id = shared
25096            .bind_spawn_session(
25097                &address,
25098                agent("claude"),
25099                SessionMode::Pty,
25100                ProviderRuntimePolicy::raw_pty(),
25101                SpawnRecordPolicy::ProviderIdentityOnly,
25102                None,
25103            )
25104            .unwrap();
25105
25106        assert!(record_id.is_none());
25107        let binding_record_id = shared
25108            .session_bindings
25109            .lock()
25110            .unwrap_or_else(|poisoned| poisoned.into_inner())
25111            .get(&address.session.instance_id)
25112            .and_then(|binding| binding.record_id.clone());
25113        assert!(binding_record_id.is_none());
25114        assert!(shared.snapshot().session_records.is_empty());
25115    }
25116
25117    #[tokio::test]
25118    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25119        let catalog = active_registry().unwrap();
25120        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25121        let workspace_id = WorkspaceId::new("primary").unwrap();
25122        let workspace = WorkspaceConfig::new(
25123            workspace_id.clone(),
25124            std::env::current_dir().unwrap(),
25125        )
25126        .unwrap();
25127        let mut shared = NodeShared::new(
25128            handle,
25129            "fixture-token".to_owned(),
25130            NodeId::new("node-raw-semantic-gate").unwrap(),
25131            vec![workspace],
25132            vec![agent("claude")],
25133        );
25134        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25135            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25136        ])
25137        .unwrap();
25138        shared
25139            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25140            .unwrap();
25141        let address = SessionAddress {
25142            workspace_id,
25143            session: SessionKey {
25144                instance_id: AgentInstanceId(72),
25145                generation: SessionGeneration::default(),
25146            },
25147        };
25148        shared.bind_session_with_policy(
25149            &address,
25150            ProviderRuntimePolicy::raw_pty(),
25151            None,
25152        );
25153        shared
25154            .handle
25155            .dispatch(shared.prepare_command(ControlCommand::Register {
25156                instance_id: address.session.instance_id,
25157                agent_id: agent("claude"),
25158                transport: TransportKind::Pty,
25159            }))
25160            .unwrap();
25161        runtime.tick().await;
25162        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25163        let revision_before = shared.handle.snapshot().revision;
25164        let history_sequence_before = shared.current_sequence();
25165        let terminal_size = gate4agent_types::TerminalSize {
25166            rows: 24,
25167            columns: 80,
25168        };
25169
25170        for request in [
25171            NodeRequest::Prompt {
25172                session: address.clone(),
25173                text: "prompt".to_owned(),
25174            },
25175            NodeRequest::Paste {
25176                session: address.clone(),
25177                text: "paste".to_owned(),
25178            },
25179            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25180            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25181            // (promptless) resume is a raw PTY relaunch and IS admitted under
25182            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25183            // in `provider_runtime.rs`; it is deliberately not exercised here.
25184            NodeRequest::Resume {
25185                session: address.clone(),
25186                terminal_size,
25187                initial_prompt: Some("resume".to_owned()),
25188            },
25189        ] {
25190            let error = process_request_inner(
25191                &shared,
25192                77,
25193                ClientRole::Operator,
25194                request,
25195            )
25196            .await
25197            .unwrap_err();
25198            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25199        }
25200
25201        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25202        assert_eq!(shared.handle.snapshot().revision, revision_before);
25203        assert_eq!(shared.current_sequence(), history_sequence_before);
25204        let bindings = shared
25205            .session_bindings
25206            .lock()
25207            .unwrap_or_else(|poisoned| poisoned.into_inner());
25208        let binding = bindings.get(&address.session.instance_id).unwrap();
25209        assert_eq!(binding.generation, address.session.generation);
25210        assert!(binding.pending_resume.is_none());
25211        assert!(binding.record_id.is_none());
25212    }
25213
25214    #[tokio::test]
25215    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25216        let catalog = active_registry().unwrap();
25217        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25218        let primary = WorkspaceConfig::new(
25219            WorkspaceId::new("primary").unwrap(),
25220            std::env::current_dir().unwrap(),
25221        )
25222        .unwrap();
25223        let shared = NodeShared::new(
25224            handle,
25225            "fixture-token".to_owned(),
25226            NodeId::new("node-1").unwrap(),
25227            vec![primary],
25228            vec![agent("claude")],
25229        );
25230        shared
25231            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25232            .unwrap();
25233        let before = shared.snapshot();
25234        let error = process_request_inner(
25235            &shared,
25236            77,
25237            ClientRole::Operator,
25238            NodeRequest::RegisterWorkspace {
25239                workspace_id: WorkspaceId::new("foreign").unwrap(),
25240                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25241            },
25242        )
25243        .await
25244        .unwrap_err();
25245        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25246        assert_eq!(error.message, "invalid-request");
25247        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25248    }
25249
25250    #[tokio::test]
25251    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25252        let catalog = active_registry().unwrap();
25253        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25254        let primary_id = WorkspaceId::new("primary").unwrap();
25255        let primary = WorkspaceConfig::new(
25256            primary_id.clone(),
25257            std::env::current_dir().unwrap(),
25258        )
25259        .unwrap();
25260        let shared = NodeShared::new(
25261            handle,
25262            "fixture-token".to_owned(),
25263            NodeId::new("node-1").unwrap(),
25264            vec![primary.clone()],
25265            vec![agent("claude")],
25266        );
25267        let secondary_id = WorkspaceId::new("secondary").unwrap();
25268        let secondary = shared
25269            .register_workspace(
25270                secondary_id.clone(),
25271                std::env::temp_dir().to_string_lossy().into_owned(),
25272            )
25273            .await
25274            .unwrap();
25275        assert_eq!(secondary.workspace_id, secondary_id);
25276        assert!(secondary.sessions.is_empty());
25277        assert_eq!(shared.snapshot().workspaces.len(), 2);
25278
25279        let duplicate_id = shared
25280            .register_workspace(
25281                secondary_id.clone(),
25282                primary.canonical_root().to_owned(),
25283            )
25284            .await
25285            .unwrap_err();
25286        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25287        let duplicate_root = shared
25288            .register_workspace(
25289                WorkspaceId::new("duplicate-root").unwrap(),
25290                windows_path_text(&secondary.canonical_root).to_owned(),
25291            )
25292            .await
25293            .unwrap_err();
25294        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25295
25296        let address = SessionAddress {
25297            workspace_id: secondary_id.clone(),
25298            session: SessionKey {
25299                instance_id: AgentInstanceId(41),
25300                generation: SessionGeneration(1),
25301            },
25302        };
25303        shared.bind_session(
25304            address.session.instance_id,
25305            secondary_id.clone(),
25306            address.session.generation,
25307        );
25308        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25309        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25310        shared.remove_binding(&address);
25311        shared.unregister_workspace(&secondary_id).unwrap();
25312        assert_eq!(shared.snapshot().workspaces.len(), 1);
25313
25314        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25315        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25316        let history = shared
25317            .history
25318            .lock()
25319            .unwrap_or_else(|poisoned| poisoned.into_inner());
25320        assert!(matches!(
25321            &history.events[0],
25322            NodeEventEnvelope {
25323                sequence: 1,
25324                event: NodeEvent::WorkspaceAdded { workspace },
25325            } if workspace.workspace_id == secondary_id
25326        ));
25327        assert!(matches!(
25328            &history.events[1],
25329            NodeEventEnvelope {
25330                sequence: 2,
25331                event: NodeEvent::WorkspaceRemoved { workspace_id },
25332            } if workspace_id == &secondary_id
25333        ));
25334    }
25335
25336    #[tokio::test]
25337    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25338        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25339        std::fs::create_dir_all(&secondary_root).unwrap();
25340        let catalog = active_registry().unwrap();
25341        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25342        let primary_id = WorkspaceId::new("primary").unwrap();
25343        let secondary_id = WorkspaceId::new("secondary").unwrap();
25344        let shared = NodeShared::new(
25345            handle,
25346            "fixture-token".to_owned(),
25347            NodeId::new("node-session-reference-index").unwrap(),
25348            vec![
25349                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25350                    .unwrap(),
25351                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25352            ],
25353            vec![agent("claude"), agent("codex")],
25354        );
25355        let identity = ProviderSessionIdentity {
25356            key: gate4agent_types::ProviderSessionKey::SessionId,
25357            id: "provider-session-42".to_owned(),
25358            transcript_path: None,
25359        };
25360        let index_request = |workspace_id: WorkspaceId,
25361                             provider: AgentId,
25362                             identity: ProviderSessionIdentity,
25363                             display_name: &str| {
25364            NodeRequest::IndexProviderSession {
25365                workspace_id,
25366                provider,
25367                identity,
25368                display_name: display_name.to_owned(),
25369            }
25370        };
25371        let request = index_request(
25372            primary_id.clone(),
25373            agent("claude"),
25374            identity.clone(),
25375            "release shepherd",
25376        );
25377        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25378            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25379        }));
25380        let observer = process_request_inner(
25381            &shared,
25382            77,
25383            ClientRole::Observer,
25384            request.clone(),
25385        )
25386        .await
25387        .unwrap_err();
25388        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25389        let uncontrolled = process_request_inner(
25390            &shared,
25391            77,
25392            ClientRole::Operator,
25393            request.clone(),
25394        )
25395        .await
25396        .unwrap_err();
25397        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25398        shared
25399            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25400            .unwrap();
25401
25402        let indexed = process_request_inner(
25403            &shared,
25404            77,
25405            ClientRole::Operator,
25406            request.clone(),
25407        )
25408        .await
25409        .unwrap();
25410        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25411            panic!("provider session reference index returned another response");
25412        };
25413        assert_eq!(record.state, ManagedSessionState::Dormant);
25414        assert_eq!(record.mode, SessionMode::Pty);
25415        assert_eq!(record.workspace_id, primary_id);
25416        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25417        assert!(record.active_session.is_none());
25418        assert!(record.environment_profile.is_none());
25419        assert!(record.bundle.is_none());
25420        assert!(record.context.is_none());
25421
25422        let repeated = process_request_inner(
25423            &shared,
25424            77,
25425            ClientRole::Operator,
25426            index_request(
25427                primary_id.clone(),
25428                agent("claude"),
25429                identity.clone(),
25430                "ignored replacement",
25431            ),
25432        )
25433        .await
25434        .unwrap();
25435        assert!(matches!(
25436            repeated,
25437            NodeResponse::ProviderSessionIndexed { record: ref existing }
25438                if existing.record_id == record.record_id
25439                    && existing.display_name == "release shepherd"
25440        ));
25441        let conflicting_workspace = process_request_inner(
25442            &shared,
25443            77,
25444            ClientRole::Operator,
25445            index_request(
25446                secondary_id,
25447                agent("claude"),
25448                identity.clone(),
25449                "release shepherd",
25450            ),
25451        )
25452        .await
25453        .unwrap_err();
25454        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25455        let conflicting_provider = process_request_inner(
25456            &shared,
25457            77,
25458            ClientRole::Operator,
25459            index_request(
25460                primary_id.clone(),
25461                agent("codex"),
25462                identity.clone(),
25463                "release shepherd",
25464            ),
25465        )
25466        .await
25467        .unwrap_err();
25468        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25469        let transcript_rejected = process_request_inner(
25470            &shared,
25471            77,
25472            ClientRole::Operator,
25473            index_request(
25474                primary_id,
25475                agent("claude"),
25476                ProviderSessionIdentity {
25477                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25478                    ..identity
25479                },
25480                "release shepherd",
25481            ),
25482        )
25483        .await
25484        .unwrap_err();
25485        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25486        let upserts = shared
25487            .history
25488            .lock()
25489            .unwrap_or_else(|poisoned| poisoned.into_inner())
25490            .events
25491            .iter()
25492            .filter(|event| matches!(
25493                &event.event,
25494                NodeEvent::SessionRecordUpserted { record: published }
25495                    if published.record_id == record.record_id
25496            ))
25497            .count();
25498        assert_eq!(upserts, 1);
25499        std::fs::remove_dir_all(secondary_root).unwrap();
25500    }
25501
25502    #[tokio::test]
25503    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25504        let fixture_root = temporary_workspace_root("standalone-independent");
25505        let target = fixture_root.join("independent");
25506        std::fs::create_dir_all(&fixture_root).unwrap();
25507        let catalog = active_registry().unwrap();
25508        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25509        let selected = WorkspaceConfig::new(
25510            WorkspaceId::new("primary").unwrap(),
25511            std::env::current_dir().unwrap(),
25512        )
25513        .unwrap();
25514        let selected_root = selected.canonical_root().to_owned();
25515        let shared = NodeShared::new(
25516            handle,
25517            "fixture-token".to_owned(),
25518            NodeId::new("node-standalone").unwrap(),
25519            vec![selected],
25520            vec![agent("claude")],
25521        );
25522        let workspace_id = WorkspaceId::new("independent").unwrap();
25523        let request = NodeRequest::CreateStandaloneWorkspace {
25524            workspace_id: workspace_id.clone(),
25525            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25526            initial_branch: Some("main".to_owned()),
25527        };
25528        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25529            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25530        }));
25531        assert!(!request_uses_unnegotiated_capability(
25532            &request,
25533            &baseline_capabilities().unwrap(),
25534        ));
25535        assert!(request_uses_unnegotiated_capability(&request, &[]));
25536
25537        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25538            .await
25539            .unwrap_err();
25540        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25541        assert!(!target.exists());
25542        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25543            .await
25544            .unwrap_err();
25545        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25546        assert!(!target.exists());
25547        shared
25548            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25549            .unwrap();
25550
25551        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25552            .await
25553            .unwrap();
25554        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25555            panic!("standalone workspace creation returned another response");
25556        };
25557        assert_eq!(workspace.workspace_id, workspace_id);
25558        assert!(target.join(".git").is_dir());
25559        let branch = run_git_bounded(
25560            target.to_str().unwrap(),
25561            &["branch", "--show-current"],
25562            GIT_OUTPUT_MAX_BYTES,
25563        )
25564        .await
25565        .unwrap();
25566        assert!(branch.success);
25567        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25568        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25569        assert_eq!(worktrees.len(), 1);
25570        let listed_root = WorkspaceConfig::new(
25571            WorkspaceId::new("listed-independent").unwrap(),
25572            &worktrees[0].path,
25573        )
25574        .unwrap();
25575        assert!(platform::roots_equal(
25576            listed_root.canonical_root(),
25577            windows_path_text(&workspace.canonical_root),
25578        ));
25579        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25580        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25581            snapshot.workspace_id == workspace_id
25582                && snapshot.canonical_root == workspace.canonical_root
25583        }));
25584        assert!(shared
25585            .history
25586            .lock()
25587            .unwrap_or_else(|poisoned| poisoned.into_inner())
25588            .events
25589            .iter()
25590            .any(|event| matches!(
25591                &event.event,
25592                NodeEvent::WorkspaceAdded { workspace }
25593                    if workspace.workspace_id == workspace_id
25594            )));
25595        std::fs::remove_dir_all(fixture_root).unwrap();
25596    }
25597
25598    #[tokio::test]
25599    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25600        let fixture_root = temporary_workspace_root("standalone-compensation");
25601        let created_target = fixture_root.join("created-target");
25602        let existing_target = fixture_root.join("existing-target");
25603        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25604        let invalid_state_path = invalid_state_parent.join("state.json");
25605        std::fs::create_dir_all(&existing_target).unwrap();
25606        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25607        let catalog = active_registry().unwrap();
25608        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25609        let primary = WorkspaceConfig::new(
25610            WorkspaceId::new("primary").unwrap(),
25611            std::env::current_dir().unwrap(),
25612        )
25613        .unwrap();
25614        let mut shared = NodeShared::new(
25615            handle,
25616            "fixture-token".to_owned(),
25617            NodeId::new("node-standalone-compensation").unwrap(),
25618            vec![primary],
25619            vec![agent("claude")],
25620        );
25621        shared.state_path = Some(invalid_state_path);
25622
25623        let created_recovery = shared
25624            .create_standalone_workspace(
25625                WorkspaceId::new("compensated").unwrap(),
25626                created_target.to_string_lossy().into_owned(),
25627                Some("main".to_owned()),
25628            )
25629            .await
25630            .unwrap_err();
25631        assert_eq!(
25632            created_recovery.code,
25633            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25634        );
25635        assert!(created_target.join(".git").is_dir());
25636        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25637            workspace.workspace_id.as_str() != "compensated"
25638        }));
25639
25640        let existing_recovery = shared
25641            .create_standalone_workspace(
25642                WorkspaceId::new("restored").unwrap(),
25643                existing_target.to_string_lossy().into_owned(),
25644                None,
25645            )
25646            .await
25647            .unwrap_err();
25648        assert_eq!(
25649            existing_recovery.code,
25650            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25651        );
25652        assert!(existing_target.is_dir());
25653        assert!(existing_target.join(".git").is_dir());
25654        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25655            workspace.workspace_id.as_str() != "restored"
25656        }));
25657
25658        let registered_empty = fixture_root.join("registered-empty");
25659        std::fs::create_dir(&registered_empty).unwrap();
25660        let catalog = active_registry().unwrap();
25661        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25662        let registered = WorkspaceConfig::new(
25663            WorkspaceId::new("registered-empty").unwrap(),
25664            &registered_empty,
25665        )
25666        .unwrap();
25667        let duplicate_shared = NodeShared::new(
25668            handle,
25669            "fixture-token".to_owned(),
25670            NodeId::new("node-standalone-duplicate-root").unwrap(),
25671            vec![registered],
25672            vec![agent("claude")],
25673        );
25674        let duplicate = duplicate_shared
25675            .create_standalone_workspace(
25676                WorkspaceId::new("duplicate-root").unwrap(),
25677                registered_empty.to_string_lossy().into_owned(),
25678                None,
25679            )
25680            .await
25681            .unwrap_err();
25682        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25683        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25684        std::fs::remove_dir_all(fixture_root).unwrap();
25685    }
25686
25687    #[test]
25688    fn managed_record_id_collision_preserves_the_existing_record() {
25689        let catalog = active_registry().unwrap();
25690        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25691        let workspace = WorkspaceConfig::new(
25692            WorkspaceId::new("primary").unwrap(),
25693            std::env::current_dir().unwrap(),
25694        )
25695        .unwrap();
25696        let shared = NodeShared::new(
25697            handle,
25698            "fixture-token".to_owned(),
25699            NodeId::new("node-1").unwrap(),
25700            vec![workspace.clone()],
25701            vec![agent("claude")],
25702        );
25703        let record_id = SessionRecordId::new("sr-collision").unwrap();
25704        let original = ManagedSessionRecord {
25705            record_id: record_id.clone(),
25706            display_name: "original".to_owned(),
25707            provider: agent("claude"),
25708            mode: SessionMode::Pty,
25709            state: ManagedSessionState::Dormant,
25710            workspace_id: workspace.workspace_id,
25711            canonical_root: opaque_windows_path(workspace.canonical_root),
25712            provider_session: None,
25713            active_session: None,
25714            environment_profile: None,
25715            bundle: None,
25716            context_id: None,
25717            context: None,
25718            exported_context: None,
25719            task_binding: None,
25720            created_at_unix_ms: 1,
25721            updated_at_unix_ms: 1,
25722            last_error: None,
25723        };
25724        shared.insert_record(original.clone()).unwrap();
25725        let mut collision = original.clone();
25726        collision.display_name = "replacement".to_owned();
25727        let error = shared.insert_record(collision).unwrap_err();
25728        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25729        assert_eq!(shared.record(&record_id).unwrap(), original);
25730    }
25731
25732    #[test]
25733    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25734        let catalog = active_registry().unwrap();
25735        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25736        let workspace = WorkspaceConfig::new(
25737            WorkspaceId::new("primary").unwrap(),
25738            std::env::current_dir().unwrap(),
25739        )
25740        .unwrap();
25741        let shared = NodeShared::new(
25742            handle,
25743            "fixture-token".to_owned(),
25744            NodeId::new("node-1").unwrap(),
25745            vec![workspace.clone()],
25746            vec![agent("claude")],
25747        );
25748        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25749        shared
25750            .insert_record(ManagedSessionRecord {
25751                record_id: record_id.clone(),
25752                display_name: "runtime error".to_owned(),
25753                provider: agent("claude"),
25754                mode: SessionMode::Pty,
25755                state: ManagedSessionState::IdentityPending,
25756                workspace_id: workspace.workspace_id,
25757                canonical_root: opaque_windows_path(workspace.canonical_root),
25758                provider_session: None,
25759                active_session: None,
25760                environment_profile: None,
25761                bundle: None,
25762                context_id: None,
25763                context: None,
25764                exported_context: None,
25765                task_binding: None,
25766                created_at_unix_ms: 1,
25767                updated_at_unix_ms: 1,
25768                last_error: None,
25769            })
25770            .unwrap();
25771
25772        shared
25773            .mark_record_error(
25774                &record_id,
25775                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25776            )
25777            .unwrap();
25778        let record = shared.record(&record_id).unwrap();
25779        assert_eq!(record.state, ManagedSessionState::Unavailable);
25780        assert_eq!(
25781            record.last_error.as_deref(),
25782            Some("provider-runtime-failed"),
25783        );
25784        let snapshot = shared.snapshot();
25785        assert_eq!(snapshot.session_records, vec![record]);
25786    }
25787
25788    /// Regression coverage for the "operator sees `deadline` instead of the
25789    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25790    /// able to learn a command's asynchronous kernel rejection (here,
25791    /// specifically classified as `UnsupportedTransport`) exactly once, and
25792    /// must not still find it after that -- proving the fast path this
25793    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25794    /// loop) drains the reason instead of leaving it to be rediscovered (or
25795    /// missed) on a later poll.
25796    #[test]
25797    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25798        let catalog = active_registry().unwrap();
25799        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25800        let workspace = WorkspaceConfig::new(
25801            WorkspaceId::new("primary").unwrap(),
25802            std::env::current_dir().unwrap(),
25803        )
25804        .unwrap();
25805        let shared = NodeShared::new(
25806            handle,
25807            "fixture-token".to_owned(),
25808            NodeId::new("node-1").unwrap(),
25809            vec![workspace],
25810            vec![agent("claude")],
25811        );
25812
25813        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25814
25815        shared.record_rejected_command(
25816            CommandId(1),
25817            "agent 'claude' does not support transport Acp".to_owned(),
25818            true,
25819        );
25820        // An unrelated command_id must not see another command's reason.
25821        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25822
25823        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25824        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25825        assert!(reason.unsupported_transport);
25826
25827        // Consumed: a second read for the same command_id finds nothing.
25828        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25829
25830        // A rejection unrelated to transport support keeps its own
25831        // classification distinct, so the reader can choose the generic
25832        // `BackendOperationFailed` code instead of misreporting
25833        // `UnsupportedTransport`.
25834        shared.record_rejected_command(
25835            CommandId(3),
25836            "backend busy".to_owned(),
25837            false,
25838        );
25839        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25840        assert!(!unclassified.unsupported_transport);
25841    }
25842
25843    #[test]
25844    fn provider_identity_update_is_ignored_without_exact_session_policy() {
25845        let catalog = active_registry().unwrap();
25846        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25847        let workspace = WorkspaceConfig::new(
25848            WorkspaceId::new("primary").unwrap(),
25849            std::env::current_dir().unwrap(),
25850        )
25851        .unwrap();
25852        let shared = NodeShared::new(
25853            handle,
25854            "fixture-token".to_owned(),
25855            NodeId::new("node-identity-policy").unwrap(),
25856            vec![workspace.clone()],
25857            vec![agent("claude")],
25858        );
25859        let address = SessionAddress {
25860            workspace_id: workspace.workspace_id.clone(),
25861            session: SessionKey {
25862                instance_id: AgentInstanceId(90),
25863                generation: SessionGeneration(1),
25864            },
25865        };
25866        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25867        shared
25868            .insert_record(ManagedSessionRecord {
25869                record_id: record_id.clone(),
25870                display_name: "policy denied".to_owned(),
25871                provider: agent("claude"),
25872                mode: SessionMode::Pty,
25873                state: ManagedSessionState::IdentityPending,
25874                workspace_id: workspace.workspace_id,
25875                canonical_root: opaque_windows_path(workspace.canonical_root),
25876                provider_session: None,
25877                active_session: Some(address.clone()),
25878                environment_profile: None,
25879                bundle: None,
25880                context_id: None,
25881                context: None,
25882                exported_context: None,
25883                task_binding: None,
25884                created_at_unix_ms: 1,
25885                updated_at_unix_ms: 1,
25886                last_error: None,
25887            })
25888            .unwrap();
25889        let event = ControlEvent {
25890            sequence: 1,
25891            command_id: None,
25892            instance_id: address.session.instance_id,
25893            generation: address.session.generation,
25894            event: ControlEventKind::ProviderEvent {
25895                sequence: 1,
25896                source: gate4agent_types::ProviderSource {
25897                    family: AdapterFamily::PtySemantic,
25898                    binding: AdapterBinding::new(
25899                        gate4agent_types::AdapterId::new("claude").unwrap(),
25900                        "fixture/v1",
25901                        gate4agent_types::AdapterVerification::SyntheticFixture,
25902                    )
25903                    .unwrap(),
25904                },
25905                source_sequence: 1,
25906                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25907                    identity: gate4agent_types::ProviderSessionIdentity {
25908                        key: gate4agent_types::ProviderSessionKey::SessionId,
25909                        id: "provider-session".to_owned(),
25910                        transcript_path: None,
25911                    },
25912                },
25913            },
25914        };
25915
25916        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25917
25918        let record = shared.record(&record_id).unwrap();
25919        assert!(record.provider_session.is_none());
25920        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25921        assert_eq!(record.active_session.as_ref(), Some(&address));
25922    }
25923
25924    #[test]
25925    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25926        let secondary_root = temporary_workspace_root("identity-scope");
25927        std::fs::create_dir_all(&secondary_root).unwrap();
25928        let catalog = active_registry().unwrap();
25929        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25930        let primary = WorkspaceConfig::new(
25931            WorkspaceId::new("primary").unwrap(),
25932            std::env::current_dir().unwrap(),
25933        )
25934        .unwrap();
25935        let secondary = WorkspaceConfig::new(
25936            WorkspaceId::new("secondary").unwrap(),
25937            &secondary_root,
25938        )
25939        .unwrap();
25940        let shared = NodeShared::new(
25941            handle,
25942            "fixture-token".to_owned(),
25943            NodeId::new("node-1").unwrap(),
25944            vec![primary.clone(), secondary.clone()],
25945            vec![agent("claude")],
25946        );
25947        let identity = gate4agent_types::ProviderSessionIdentity {
25948            key: gate4agent_types::ProviderSessionKey::SessionId,
25949            id: "provider-session".to_owned(),
25950            transcript_path: None,
25951        };
25952        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25953        shared
25954            .insert_record(ManagedSessionRecord {
25955                record_id: dormant_id.clone(),
25956                display_name: "dormant".to_owned(),
25957                provider: agent("claude"),
25958                mode: SessionMode::Pty,
25959                state: ManagedSessionState::Dormant,
25960                workspace_id: primary.workspace_id,
25961                canonical_root: opaque_windows_path(primary.canonical_root),
25962                provider_session: Some(identity.clone()),
25963                active_session: None,
25964                environment_profile: None,
25965                bundle: None,
25966                context_id: None,
25967                context: None,
25968                exported_context: None,
25969                task_binding: None,
25970                created_at_unix_ms: 1,
25971                updated_at_unix_ms: 1,
25972                last_error: None,
25973            })
25974            .unwrap();
25975        let address = SessionAddress {
25976            workspace_id: secondary.workspace_id.clone(),
25977            session: SessionKey {
25978                instance_id: AgentInstanceId(91),
25979                generation: SessionGeneration(1),
25980            },
25981        };
25982        let current_id = SessionRecordId::new("sr-current").unwrap();
25983        shared
25984            .insert_record(ManagedSessionRecord {
25985                record_id: current_id.clone(),
25986                display_name: "current".to_owned(),
25987                provider: agent("claude"),
25988                mode: SessionMode::Pty,
25989                state: ManagedSessionState::IdentityPending,
25990                workspace_id: secondary.workspace_id,
25991                canonical_root: opaque_windows_path(secondary.canonical_root),
25992                provider_session: None,
25993                active_session: Some(address.clone()),
25994                environment_profile: None,
25995                bundle: None,
25996                context_id: None,
25997                context: None,
25998                exported_context: None,
25999                task_binding: None,
26000                created_at_unix_ms: 2,
26001                updated_at_unix_ms: 2,
26002                last_error: None,
26003            })
26004            .unwrap();
26005        shared.bind_managed_session(
26006            &address,
26007            current_id.clone(),
26008            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26009            None,
26010        );
26011        let event = ControlEvent {
26012            sequence: 1,
26013            command_id: None,
26014            instance_id: address.session.instance_id,
26015            generation: address.session.generation,
26016            event: ControlEventKind::ProviderEvent {
26017                sequence: 1,
26018                source: gate4agent_types::ProviderSource {
26019                    family: gate4agent_types::AdapterFamily::PtySemantic,
26020                    binding: gate4agent_types::AdapterBinding::new(
26021                        gate4agent_types::AdapterId::new("claude").unwrap(),
26022                        "fixture/v1",
26023                        gate4agent_types::AdapterVerification::SyntheticFixture,
26024                    )
26025                    .unwrap(),
26026                },
26027                source_sequence: 1,
26028                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26029                    identity,
26030                },
26031            },
26032        };
26033        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26034
26035        let binding = shared
26036            .session_bindings
26037            .lock()
26038            .unwrap_or_else(|poisoned| poisoned.into_inner())
26039            .get(&address.session.instance_id)
26040            .cloned()
26041            .unwrap();
26042        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26043        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26044        let current = shared.record(&current_id).unwrap();
26045        assert_eq!(current.state, ManagedSessionState::Unavailable);
26046        assert_eq!(current.active_session.as_ref(), Some(&address));
26047        assert_eq!(
26048            current.last_error.as_deref(),
26049            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26050        );
26051        std::fs::remove_dir_all(secondary_root).unwrap();
26052    }
26053
26054    #[tokio::test]
26055    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26056        let root = temporary_workspace_root("semantic-provider-session");
26057        std::fs::create_dir_all(&root).unwrap();
26058        let state_path = root.join("state-v1.json");
26059        let catalog = active_registry().unwrap();
26060        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26061        let workspace = WorkspaceConfig::new(
26062            WorkspaceId::new("primary").unwrap(),
26063            &root,
26064        )
26065        .unwrap();
26066        let node_id = NodeId::new("node-semantic-identity").unwrap();
26067        let shared = NodeShared::new_with_incarnation(
26068            handle,
26069            "fixture-token".to_owned(),
26070            node_id.clone(),
26071            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26072            vec![workspace.clone()],
26073            vec![agent("claude")],
26074            ProviderRuntimeStatuses::default(),
26075            None,
26076            Vec::new(),
26077            Vec::new(),
26078            SpawnProfileRegistry::default(),
26079            None,
26080            None,
26081            None,
26082            Some(state_path.clone()),
26083            Vec::new(),
26084            Vec::new(),
26085            Vec::new(),
26086            Vec::new(),
26087            None,
26088            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26089        );
26090        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26091            key: gate4agent_types::ProviderSessionKey::SessionId,
26092            id: "provider-session".to_owned(),
26093            transcript_path: None,
26094        };
26095        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26096        shared
26097            .insert_record(ManagedSessionRecord {
26098                record_id: durable_id.clone(),
26099                display_name: "durable".to_owned(),
26100                provider: agent("claude"),
26101                mode: SessionMode::Pty,
26102                state: ManagedSessionState::Dormant,
26103                workspace_id: workspace.workspace_id.clone(),
26104                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26105                provider_session: Some(persisted_identity.clone()),
26106                active_session: None,
26107                environment_profile: None,
26108                bundle: None,
26109                context_id: None,
26110                context: None,
26111                exported_context: None,
26112                task_binding: None,
26113                created_at_unix_ms: 1,
26114                updated_at_unix_ms: 1,
26115                last_error: None,
26116            })
26117            .unwrap();
26118        let address = SessionAddress {
26119            workspace_id: workspace.workspace_id.clone(),
26120            session: SessionKey {
26121                instance_id: AgentInstanceId(92),
26122                generation: SessionGeneration(2),
26123            },
26124        };
26125        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26126        shared
26127            .insert_record(ManagedSessionRecord {
26128                record_id: pending_id.clone(),
26129                display_name: "pending".to_owned(),
26130                provider: agent("claude"),
26131                mode: SessionMode::Pty,
26132                state: ManagedSessionState::IdentityPending,
26133                workspace_id: workspace.workspace_id.clone(),
26134                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26135                provider_session: None,
26136                active_session: Some(address.clone()),
26137                environment_profile: None,
26138                bundle: None,
26139                context_id: None,
26140                context: None,
26141                exported_context: None,
26142                task_binding: None,
26143                created_at_unix_ms: 2,
26144                updated_at_unix_ms: 2,
26145                last_error: None,
26146            })
26147            .unwrap();
26148        shared.bind_managed_session(
26149            &address,
26150            pending_id.clone(),
26151            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26152            None,
26153        );
26154        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26155            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26156            ..persisted_identity
26157        };
26158        let event = ControlEvent {
26159            sequence: 1,
26160            command_id: None,
26161            instance_id: address.session.instance_id,
26162            generation: address.session.generation,
26163            event: ControlEventKind::ProviderEvent {
26164                sequence: 1,
26165                source: gate4agent_types::ProviderSource {
26166                    family: gate4agent_types::AdapterFamily::PtySemantic,
26167                    binding: gate4agent_types::AdapterBinding::new(
26168                        gate4agent_types::AdapterId::new("claude").unwrap(),
26169                        "fixture/v1",
26170                        gate4agent_types::AdapterVerification::SyntheticFixture,
26171                    )
26172                    .unwrap(),
26173                },
26174                source_sequence: 1,
26175                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26176                    identity: observed_identity.clone(),
26177                },
26178            },
26179        };
26180        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26181
26182        let snapshot = shared.snapshot();
26183        assert_eq!(snapshot.session_records.len(), 1);
26184        let live = &snapshot.session_records[0];
26185        assert_eq!(live.record_id, durable_id);
26186        assert_eq!(live.state, ManagedSessionState::Live);
26187        assert_eq!(live.active_session.as_ref(), Some(&address));
26188        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26189        let binding = shared
26190            .session_bindings
26191            .lock()
26192            .unwrap_or_else(|poisoned| poisoned.into_inner())
26193            .get(&address.session.instance_id)
26194            .cloned()
26195            .unwrap();
26196        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26197
26198        let mut duplicate = live.clone();
26199        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26200        duplicate.display_name = "duplicate".to_owned();
26201        duplicate.state = ManagedSessionState::Dormant;
26202        duplicate.active_session = None;
26203        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26204        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26205        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26206        assert_eq!(shared.snapshot().session_records.len(), 1);
26207
26208        let terminal_size = gate4agent_types::TerminalSize {
26209            rows: 24,
26210            columns: 80,
26211        };
26212        let (first_busy, second_busy) = tokio::join!(
26213            shared.resume_session_record(&durable_id, terminal_size, None),
26214            shared.resume_session_record(&durable_id, terminal_size, None),
26215        );
26216        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26217            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26218            assert_eq!(busy.message, "session-record-busy");
26219        }
26220
26221        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26222        assert_eq!(loaded.records.len(), 1);
26223        assert_eq!(loaded.records[0].record_id, durable_id);
26224        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26225        assert_eq!(
26226            loaded.records[0]
26227                .provider_session
26228                .as_ref()
26229                .unwrap()
26230                .transcript_path,
26231            None,
26232        );
26233        let catalog = active_registry().unwrap();
26234        let (reloaded_handle, _reloaded_runtime) =
26235            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26236        let reloaded = NodeShared::new_with_incarnation(
26237            reloaded_handle,
26238            "fixture-token".to_owned(),
26239            node_id,
26240            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26241            vec![workspace],
26242            vec![agent("claude")],
26243            ProviderRuntimeStatuses::default(),
26244            None,
26245            Vec::new(),
26246            Vec::new(),
26247            SpawnProfileRegistry::default(),
26248            None,
26249            None,
26250            None,
26251            None,
26252            loaded.records,
26253            loaded.managed_worktrees,
26254            loaded.managed_worktree_tombstones,
26255            loaded.materializations,
26256            loaded.warning,
26257            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26258        );
26259        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26260        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26261        std::fs::remove_dir_all(root).unwrap();
26262    }
26263
26264    #[tokio::test]
26265    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26266        let secondary_root = temporary_workspace_root("workspace-restore");
26267        let other_root = temporary_workspace_root("workspace-conflict");
26268        std::fs::create_dir_all(&secondary_root).unwrap();
26269        std::fs::create_dir_all(&other_root).unwrap();
26270        let catalog = active_registry().unwrap();
26271        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26272        let primary = WorkspaceConfig::new(
26273            WorkspaceId::new("primary").unwrap(),
26274            std::env::current_dir().unwrap(),
26275        )
26276        .unwrap();
26277        let secondary = WorkspaceConfig::new(
26278            WorkspaceId::new("secondary").unwrap(),
26279            &secondary_root,
26280        )
26281        .unwrap();
26282        let shared = NodeShared::new(
26283            handle,
26284            "fixture-token".to_owned(),
26285            NodeId::new("node-1").unwrap(),
26286            vec![primary, secondary.clone()],
26287            vec![agent("claude")],
26288        );
26289        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26290        shared
26291            .insert_record(ManagedSessionRecord {
26292                record_id: record_id.clone(),
26293                display_name: "workspace record".to_owned(),
26294                provider: agent("claude"),
26295                mode: SessionMode::Pty,
26296                state: ManagedSessionState::Dormant,
26297                workspace_id: secondary.workspace_id.clone(),
26298                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26299                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26300                    key: gate4agent_types::ProviderSessionKey::SessionId,
26301                    id: "provider-session".to_owned(),
26302                    transcript_path: None,
26303                }),
26304                active_session: None,
26305                environment_profile: None,
26306                bundle: None,
26307                context_id: None,
26308                context: None,
26309                exported_context: None,
26310                task_binding: None,
26311                created_at_unix_ms: 1,
26312                updated_at_unix_ms: 1,
26313                last_error: None,
26314            })
26315            .unwrap();
26316        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26317        let unavailable = shared.record(&record_id).unwrap();
26318        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26319        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26320
26321        let conflict = shared
26322            .register_workspace(
26323                secondary.workspace_id.clone(),
26324                other_root.to_string_lossy().into_owned(),
26325            )
26326            .await
26327            .unwrap_err();
26328        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26329        shared
26330            .register_workspace(
26331                secondary.workspace_id,
26332                secondary_root.to_string_lossy().into_owned(),
26333            )
26334            .await
26335            .unwrap();
26336        let restored = shared.record(&record_id).unwrap();
26337        assert_eq!(restored.state, ManagedSessionState::Dormant);
26338        assert!(restored.last_error.is_none());
26339        std::fs::remove_dir_all(secondary_root).unwrap();
26340        std::fs::remove_dir_all(other_root).unwrap();
26341    }
26342
26343    #[test]
26344    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26345        let error = validate_workspace_request_root(
26346            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26347        )
26348        .unwrap_err();
26349        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26350        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26351        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26352    }
26353
26354    #[test]
26355    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26356        let catalog = active_registry().unwrap();
26357        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26358        let workspace = WorkspaceConfig::new(
26359            WorkspaceId::new("primary").unwrap(),
26360            std::env::current_dir().unwrap(),
26361        )
26362        .unwrap();
26363        let shared = NodeShared::new(
26364            handle,
26365            "fixture-token".to_owned(),
26366            NodeId::new("node-1").unwrap(),
26367            vec![workspace],
26368            vec![agent("claude")],
26369        );
26370        let publishers = 4_u64;
26371        let events_per_publisher = 256_u64;
26372        let start = std::sync::Barrier::new(publishers as usize + 1);
26373        let finished = AtomicU64::new(0);
26374
26375        std::thread::scope(|scope| {
26376            for _ in 0..publishers {
26377                scope.spawn(|| {
26378                    start.wait();
26379                    for _ in 0..events_per_publisher {
26380                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26381                    }
26382                    finished.fetch_add(1, Ordering::Release);
26383                });
26384            }
26385            start.wait();
26386            while finished.load(Ordering::Acquire) != publishers {
26387                assert_resync_history_is_linearized(shared.resync(0));
26388                std::thread::yield_now();
26389            }
26390        });
26391        let response = shared.resync(0);
26392        assert_resync_history_is_linearized(response.clone());
26393        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26394            unreachable!("resync helper returned another response");
26395        };
26396        assert_eq!(event_sequence, publishers * events_per_publisher);
26397        assert_eq!(events.len() as u64, event_sequence);
26398    }
26399
26400    fn assert_resync_history_is_linearized(response: NodeResponse) {
26401        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26402            unreachable!("resync helper returned another response");
26403        };
26404        assert!(events.windows(2).all(|pair| {
26405            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26406        }));
26407        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26408    }
26409
26410    #[test]
26411    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26412        assert_eq!(
26413            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26414            r"C:\repo\worktree",
26415        );
26416        assert_eq!(
26417            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26418            r"\\server\share\repo",
26419        );
26420        assert!(validate_workspace_root(
26421            &WorkspaceId::new("network").unwrap(),
26422            r"\\server\share\repo",
26423        )
26424        .is_err());
26425        let workspace = WorkspaceConfig::new(
26426            WorkspaceId::new("primary").unwrap(),
26427            std::env::current_dir().unwrap(),
26428        )
26429        .unwrap();
26430        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26431    }
26432
26433    #[test]
26434    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26435        let catalog = active_registry().unwrap();
26436        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26437        let workspace_id = WorkspaceId::new("primary").unwrap();
26438        let workspace = WorkspaceConfig::new(
26439            workspace_id.clone(),
26440            std::env::current_dir().unwrap(),
26441        )
26442        .unwrap();
26443        let shared = NodeShared::new(
26444            handle,
26445            "fixture-token".to_owned(),
26446            NodeId::new("node-1").unwrap(),
26447            vec![workspace],
26448            vec![agent("claude")],
26449        );
26450        let instance_id = AgentInstanceId(17);
26451        let original = SessionAddress {
26452            workspace_id,
26453            session: SessionKey {
26454                instance_id,
26455                generation: SessionGeneration(3),
26456            },
26457        };
26458        shared.bind_session(
26459            instance_id,
26460            original.workspace_id.clone(),
26461            original.session.generation,
26462        );
26463        shared
26464            .arm_resume(
26465                &original,
26466                CommandId(41),
26467                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26468            )
26469            .unwrap();
26470
26471        shared.publish_control(ControlEvent {
26472            sequence: 1,
26473            command_id: None,
26474            instance_id,
26475            generation: SessionGeneration(4),
26476            event: ControlEventKind::Running { process_id: None },
26477        });
26478        assert_eq!(
26479            shared.address_for(instance_id, SessionGeneration(3)),
26480            Some(original.clone()),
26481        );
26482        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26483
26484        shared.publish_control(ControlEvent {
26485            sequence: 2,
26486            command_id: Some(CommandId(42)),
26487            instance_id,
26488            generation: SessionGeneration(3),
26489            event: ControlEventKind::CommandRejected {
26490                message: "unrelated command".to_owned(),
26491            },
26492        });
26493
26494        shared.publish_control(ControlEvent {
26495            sequence: 3,
26496            command_id: None,
26497            instance_id,
26498            generation: SessionGeneration(4),
26499            event: ControlEventKind::ResumeAuthorized {
26500                session: gate4agent_types::ResumeSessionSummary {
26501                    key: gate4agent_types::ProviderSessionKey::SessionId,
26502                    id: "fixture-session".to_owned(),
26503                },
26504            },
26505        });
26506        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26507        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26508        assert_eq!(rebound.workspace_id, original.workspace_id);
26509        let history = shared
26510            .history
26511            .lock()
26512            .unwrap_or_else(|poisoned| poisoned.into_inner());
26513        assert!(history.events.iter().any(|envelope| matches!(
26514            &envelope.event,
26515            NodeEvent::Control { address, event }
26516                if address == &rebound
26517                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26518        )));
26519    }
26520
26521    #[test]
26522    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26523        for agent in ["claude", "codex", "kimi"] {
26524            assert_eq!(
26525                prompt_framing(&AgentId::new(agent).unwrap()),
26526                PromptFraming::BracketedPaste
26527            );
26528        }
26529    }
26530
26531    fn generous_walk_deadline() -> Instant {
26532        Instant::now() + Duration::from_secs(30)
26533    }
26534
26535    #[test]
26536    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26537        let root = temporary_workspace_root("tree");
26538        std::fs::create_dir_all(root.join("src")).unwrap();
26539        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26540        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26541        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26542        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26543        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26544        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26545        // real repository's own build layout) that carries the Cache
26546        // Directory Tagging Specification marker cargo writes into every
26547        // `--target-dir` it creates: must be skipped even though its name
26548        // does not exactly match `target`.
26549        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26550        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26551        // A directory that merely *starts with* `target` but carries no
26552        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26553        // on the name alone, so this stays in the response.
26554        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26555        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26556
26557        let (entries, truncated, _budget) =
26558            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26559        assert!(!truncated);
26560        assert!(entries.iter().any(|entry| {
26561            entry.relative_path.as_utf8() == Some("src")
26562                && entry.kind == WorkspaceEntryKind::Directory
26563        }));
26564        assert!(entries.iter().any(|entry| {
26565            entry.relative_path.as_utf8() == Some("src/lib.rs")
26566                && entry.kind == WorkspaceEntryKind::File
26567        }));
26568        assert!(entries.iter().any(|entry| {
26569            entry.relative_path.as_utf8() == Some("target-notes")
26570                && entry.kind == WorkspaceEntryKind::Directory
26571        }));
26572        assert!(entries.iter().any(|entry| {
26573            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26574                && entry.kind == WorkspaceEntryKind::File
26575        }));
26576        assert!(entries.iter().all(|entry| {
26577            let path = entry.relative_path.as_utf8().unwrap();
26578            !path.starts_with(".git")
26579                && !(path == "target" || path.starts_with("target/"))
26580                && !(path == "target-a2" || path.starts_with("target-a2/"))
26581                && !path.starts_with("node_modules")
26582        }));
26583
26584        std::fs::remove_dir_all(&root).unwrap();
26585    }
26586
26587    #[test]
26588    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26589        let root = temporary_workspace_root("tree-entry-cap");
26590        std::fs::create_dir_all(&root).unwrap();
26591        for index in 0..20 {
26592            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26593        }
26594
26595        let (entries, truncated, budget) =
26596            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26597        assert!(truncated);
26598        assert!(budget.entry_cap_exceeded);
26599        assert!(!budget.time_budget_exceeded);
26600        assert!(entries.len() <= 5);
26601
26602        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26603            .expect("an entry-cap-truncated walk must report a truncation marker");
26604        assert!(inspection_truncation.walk_entry_cap_exceeded);
26605        assert!(!inspection_truncation.walk_time_budget_exceeded);
26606        assert!(!inspection_truncation.git_time_budget_exceeded);
26607        assert!(inspection_truncation.entries_visited >= 5);
26608
26609        std::fs::remove_dir_all(&root).unwrap();
26610    }
26611
26612    #[test]
26613    fn workspace_walk_within_budget_reports_no_truncation() {
26614        let root = temporary_workspace_root("tree-no-truncation");
26615        std::fs::create_dir_all(&root).unwrap();
26616        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26617
26618        let (_entries, truncated, budget) =
26619            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26620        assert!(!truncated);
26621        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26622
26623        std::fs::remove_dir_all(&root).unwrap();
26624    }
26625
26626    #[test]
26627    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26628        let root = temporary_workspace_root("tree-prefix-siblings");
26629        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26630        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26631        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26632        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26633
26634        let (entries, truncated, _budget) =
26635            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26636        assert!(!truncated);
26637        let paths = entries
26638            .iter()
26639            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26640            .collect::<Vec<_>>();
26641        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26642        assert_eq!(
26643            paths,
26644            vec![
26645                "gate4agent-c2".to_owned(),
26646                "gate4agent-c2-client".to_owned(),
26647                "gate4agent-c2-client/lib.rs".to_owned(),
26648                "gate4agent-c2/src".to_owned(),
26649                "gate4agent-c2/src/lib.rs".to_owned(),
26650            ],
26651        );
26652
26653        std::fs::remove_dir_all(&root).unwrap();
26654    }
26655
26656    #[test]
26657    fn git_parsers_cap_status_and_commit_results() {
26658        let mut status_output = Vec::new();
26659        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26660            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26661        }
26662        let mut snapshot = git_snapshot_for_parser();
26663        parse_git_status(&status_output, &mut snapshot);
26664        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26665        assert!(snapshot.truncated);
26666        assert_eq!(snapshot.status[0].index_status, " ");
26667        assert_eq!(snapshot.status[0].worktree_status, "M");
26668
26669        snapshot.truncated = false;
26670        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26671            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26672            .collect::<String>();
26673        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26674        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26675        assert!(snapshot.truncated);
26676    }
26677
26678    #[test]
26679    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26680        let mut snapshot = git_snapshot_for_parser();
26681        parse_git_status(
26682            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26683            &mut snapshot,
26684        );
26685
26686        assert!(!snapshot.truncated);
26687        assert_eq!(snapshot.status.len(), 3);
26688        assert_eq!(snapshot.status[0].index_status, "R");
26689        assert_eq!(snapshot.status[0].worktree_status, " ");
26690        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26691        assert_eq!(
26692            snapshot.status[0]
26693                .previous_path
26694                .as_ref()
26695                .and_then(RepositoryPath::as_utf8),
26696            Some("src/previous name.rs"),
26697        );
26698        assert_eq!(snapshot.status[1].index_status, " ");
26699        assert_eq!(snapshot.status[1].worktree_status, "C");
26700        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26701        assert_eq!(
26702            snapshot.status[1]
26703                .previous_path
26704                .as_ref()
26705                .and_then(RepositoryPath::as_utf8),
26706            Some("src/original.rs"),
26707        );
26708        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26709        assert!(snapshot.status[2].previous_path.is_none());
26710    }
26711
26712    #[test]
26713    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26714        let mut snapshot = git_snapshot_for_parser();
26715        parse_git_status(
26716            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26717            &mut snapshot,
26718        );
26719
26720        assert!(snapshot.truncated);
26721        assert_eq!(snapshot.status.len(), 1);
26722        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26723        assert!(snapshot.status[0].previous_path.is_none());
26724    }
26725
26726    #[tokio::test]
26727    async fn observer_can_browse_host_directories_without_controller() {
26728        let root = temporary_workspace_root("host-directory-observer");
26729        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26730        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26731        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26732        let workspace_id = WorkspaceId::new("primary").unwrap();
26733        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26734        let catalog = active_registry().unwrap();
26735        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26736        let shared = NodeShared::new(
26737            handle,
26738            "fixture-token".to_owned(),
26739            NodeId::new("node-1").unwrap(),
26740            vec![workspace],
26741            vec![agent("claude")],
26742        );
26743        let request = NodeRequest::BrowseHostDirectories {
26744            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26745            after: None,
26746        };
26747        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26748            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26749        }));
26750        assert!(!request_uses_unnegotiated_capability(
26751            &request,
26752            &baseline_capabilities().unwrap(),
26753        ));
26754        assert!(request_uses_unnegotiated_capability(&request, &[]));
26755
26756        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26757            .await
26758            .unwrap();
26759        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26760            panic!("host directory browse returned another response");
26761        };
26762        assert_eq!(
26763            listing
26764                .entries
26765                .iter()
26766                .map(|entry| entry.display_name.as_str())
26767                .collect::<Vec<_>>(),
26768            vec!["directory-a", "directory-b"],
26769        );
26770        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26771        assert!(!listing.incomplete);
26772        std::fs::remove_dir_all(root).unwrap();
26773    }
26774
26775    #[tokio::test]
26776    async fn observer_can_inspect_only_a_registered_workspace() {
26777        let root = temporary_workspace_root("observer");
26778        std::fs::create_dir_all(root.join("src")).unwrap();
26779        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26780        let workspace_id = WorkspaceId::new("primary").unwrap();
26781        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26782        let catalog = active_registry().unwrap();
26783        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26784        let shared = NodeShared::new(
26785            handle,
26786            "fixture-token".to_owned(),
26787            NodeId::new("node-1").unwrap(),
26788            vec![workspace],
26789            vec![agent("claude")],
26790        );
26791
26792        let response = process_request_inner(
26793            &shared,
26794            77,
26795            ClientRole::Observer,
26796            NodeRequest::InspectWorkspace {
26797                workspace_id: workspace_id.clone(),
26798            },
26799        )
26800        .await
26801        .unwrap();
26802        let NodeResponse::WorkspaceInspected { inspection } = response else {
26803            panic!("workspace inspection returned another response");
26804        };
26805        assert_eq!(inspection.workspace_id, workspace_id);
26806        assert!(inspection.entries.iter().any(|entry| {
26807            entry.relative_path.as_utf8() == Some("src/main.rs")
26808        }));
26809
26810        let unknown = process_request_inner(
26811            &shared,
26812            77,
26813            ClientRole::Observer,
26814            NodeRequest::InspectWorkspace {
26815                workspace_id: WorkspaceId::new("unknown").unwrap(),
26816            },
26817        )
26818        .await
26819        .unwrap_err();
26820        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26821        std::fs::remove_dir_all(&root).unwrap();
26822    }
26823
26824    #[test]
26825    fn context_pack_source_accepts_only_running_or_successful_exit() {
26826        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26827        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26828            exit_code: Some(0),
26829        }));
26830        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26831            exit_code: Some(1),
26832        }));
26833        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26834            exit_code: None,
26835        }));
26836        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26837            message: "provider failed".to_owned(),
26838        }));
26839        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26840        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26841        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26842    }
26843
26844    #[test]
26845    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26846        let git = git_snapshot_for_parser();
26847        let first = ContextPackRepositoryObservation {
26848            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26849            git: git.clone(),
26850            files: Vec::new(),
26851        };
26852        let second = ContextPackRepositoryObservation {
26853            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26854            git,
26855            files: Vec::new(),
26856        };
26857
26858        let error = stable_context_pack_repository(first, second).unwrap_err();
26859
26860        assert_eq!(
26861            error.code,
26862            NodeFailureCode::ContextPackMaterializationFailed,
26863        );
26864    }
26865
26866    #[test]
26867    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26868        let git = git_snapshot_for_parser();
26869        let first = ContextPackRepositoryObservation {
26870            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26871            git: git.clone(),
26872            files: vec![ContextPackRepositoryFileSource::utf8(
26873                "README.md",
26874                "first\n".to_owned(),
26875                6,
26876            )],
26877        };
26878        let second = ContextPackRepositoryObservation {
26879            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26880            git,
26881            files: vec![ContextPackRepositoryFileSource::utf8(
26882                "README.md",
26883                "other\n".to_owned(),
26884                6,
26885            )],
26886        };
26887
26888        let error = stable_context_pack_repository(first, second).unwrap_err();
26889
26890        assert_eq!(
26891            error.code,
26892            NodeFailureCode::ContextPackMaterializationFailed,
26893        );
26894    }
26895
26896    #[tokio::test]
26897    async fn native_session_catalog_zero_active_session_fixture() {
26898        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26899        let history_root = temporary_workspace_root("native-catalog-history");
26900        let outside_root = temporary_workspace_root("native-catalog-outside");
26901        std::fs::create_dir_all(&workspace_root).unwrap();
26902        std::fs::create_dir_all(&history_root).unwrap();
26903        std::fs::create_dir_all(&outside_root).unwrap();
26904        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26905            [
26906                serde_json::json!({
26907                    "type": "user",
26908                    "sessionId": session_id,
26909                    "cwd": cwd.to_str().unwrap(),
26910                    "message": { "content": "raw user text must not cross the catalog wire" }
26911                }),
26912                serde_json::json!({
26913                    "type": "assistant",
26914                    "sessionId": session_id,
26915                    "cwd": cwd.to_str().unwrap(),
26916                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26917                }),
26918                serde_json::json!({
26919                    "type": "ai-title",
26920                    "sessionId": session_id,
26921                    "aiTitle": title
26922                }),
26923            ]
26924            .into_iter()
26925            .map(|record| record.to_string())
26926            .collect::<Vec<_>>()
26927            .join("\n")
26928        };
26929        std::fs::write(
26930            history_root.join("stable-session.jsonl"),
26931            transcript("stable-session", &workspace_root, "Workspace review"),
26932        )
26933        .unwrap();
26934        std::fs::write(
26935            history_root.join("outside-session.jsonl"),
26936            transcript("outside-session", &outside_root, "Outside review"),
26937        )
26938        .unwrap();
26939        let history = NativeHistoryConfig::new(vec![
26940            NativeHistoryRoot::new(
26941                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26942                HistorySourceLayout::SingleNdjson,
26943                history_root.clone(),
26944            )
26945            .unwrap(),
26946        ])
26947        .unwrap();
26948        let catalog = active_registry().unwrap();
26949        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26950        assert!(handle.snapshot().sessions.is_empty());
26951        let workspace_id = WorkspaceId::new("primary").unwrap();
26952        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26953        let mut shared = NodeShared::new(
26954            handle,
26955            "fixture-token".to_owned(),
26956            NodeId::new("node-native-catalog").unwrap(),
26957            vec![workspace],
26958            vec![agent("claude")],
26959        );
26960        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26961            NativeSessionCatalogAuthority::new(history),
26962        )));
26963        let route = NativeSessionCatalogRoute::workspace(
26964            workspace_id.clone(),
26965            agent("claude"),
26966        );
26967        let request = NodeRequest::CatalogNativeSessions {
26968            route: route.clone(),
26969            limit: 2,
26970        };
26971        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26972            .await
26973            .unwrap_err();
26974        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26975        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26976            .await
26977            .unwrap();
26978        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26979            panic!("native catalog returned a different response");
26980        };
26981        let summary = summary.expect("node must emit native session catalog summary metadata");
26982        assert_eq!(entries.len(), 1);
26983        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26984        assert_eq!(entries[0].external_group, None);
26985        assert_eq!(entries[0].record_id, None);
26986        assert_eq!(entries[0].title, None);
26987        assert_eq!(entries[0].model, None);
26988        assert_eq!(entries[0].message_count, 0);
26989        let encoded_entries = serde_json::to_string(&entries).unwrap();
26990        assert!(!encoded_entries.contains("stable-session"));
26991        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26992        let page_request = NodeRequest::PageNativeSessions {
26993            route: route.clone(),
26994            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26995            catalog_revision: summary.catalog_revision,
26996            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26997            after_selection_id: None,
26998            limit: 2,
26999        };
27000        let observer = process_request_inner(
27001            &shared,
27002            7,
27003            ClientRole::Observer,
27004            page_request.clone(),
27005        )
27006        .await
27007        .unwrap_err();
27008        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27009        let page_response = process_request_inner(
27010            &shared,
27011            7,
27012            ClientRole::Operator,
27013            page_request.clone(),
27014        )
27015        .await
27016        .unwrap();
27017        assert!(matches!(
27018            page_response,
27019            NodeResponse::NativeSessionsPaged { page, .. }
27020                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27021        ));
27022        let stale = process_request_inner(
27023            &shared,
27024            7,
27025            ClientRole::Operator,
27026            NodeRequest::PageNativeSessions {
27027                route: route.clone(),
27028                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27029                catalog_revision: summary.catalog_revision.wrapping_add(1),
27030                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27031                after_selection_id: None,
27032                limit: 2,
27033            },
27034        )
27035        .await
27036        .unwrap_err();
27037        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27038        let selection = NativeSessionSelection {
27039            route: route.clone(),
27040            catalog_revision: summary.catalog_revision,
27041            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27042            selection_id: entries[0].selection_id.clone(),
27043        };
27044        let preview_response = process_request_inner(
27045            &shared,
27046            7,
27047            ClientRole::Operator,
27048            NodeRequest::PreviewNativeSession {
27049                selection: selection.clone(),
27050                message_limit: 1,
27051            },
27052        )
27053        .await
27054        .unwrap();
27055        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27056            panic!("native preview returned a different response");
27057        };
27058        assert_eq!(preview.messages.len(), 1);
27059        assert!(preview.message_count_exact);
27060        assert_eq!(preview.messages[0].text, "raw answer");
27061        assert!(preview.truncated);
27062
27063        let index_request = NodeRequest::IndexNativeSession {
27064            selection: selection.clone(),
27065            display_name: "Saved Claude".to_owned(),
27066        };
27067        let observer = process_request_inner(
27068            &shared,
27069            7,
27070            ClientRole::Observer,
27071            index_request.clone(),
27072        )
27073        .await
27074        .unwrap_err();
27075        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27076        let without_controller = process_request_inner(
27077            &shared,
27078            7,
27079            ClientRole::Operator,
27080            index_request.clone(),
27081        )
27082        .await
27083        .unwrap_err();
27084        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27085        let acquired = process_request_inner(
27086            &shared,
27087            7,
27088            ClientRole::Operator,
27089            NodeRequest::AcquireController { lease_ms: 5_000 },
27090        )
27091        .await
27092        .unwrap();
27093        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27094        let indexed = process_request_inner(
27095            &shared,
27096            7,
27097            ClientRole::Operator,
27098            index_request,
27099        )
27100        .await
27101        .unwrap();
27102        assert!(indexed.native_session_index_contract_is_valid());
27103        let NodeResponse::NativeSessionIndexed {
27104            selection: echoed_selection,
27105            record,
27106        } = indexed else {
27107            panic!("native session index returned a different response");
27108        };
27109        assert_eq!(echoed_selection, selection);
27110        let recataloged = process_request_inner(
27111            &shared,
27112            7,
27113            ClientRole::Operator,
27114            NodeRequest::CatalogNativeSessions {
27115                route,
27116                limit: 2,
27117            },
27118        )
27119        .await
27120        .unwrap();
27121        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27122            panic!("native catalog returned a different response after indexing");
27123        };
27124        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27125
27126        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27127        let external_catalog = process_request_inner(
27128            &shared,
27129            7,
27130            ClientRole::Operator,
27131            NodeRequest::CatalogNativeSessions {
27132                route: unregistered_route.clone(),
27133                limit: 2,
27134            },
27135        )
27136        .await
27137        .unwrap();
27138        let external_json = serde_json::to_string(&external_catalog).unwrap();
27139        assert!(!external_json.contains("outside-session"));
27140        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27141        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27142        assert!(!external_json.contains("\"session_id\""));
27143        assert!(!external_json.contains("\"cwd\""));
27144        assert!(!external_json.contains("\"path\""));
27145        let NodeResponse::NativeSessionsCataloged {
27146            route: echoed_route,
27147            entries: external_entries,
27148            summary: Some(external_summary),
27149        } = external_catalog
27150        else {
27151            panic!("unregistered native catalog returned a different response");
27152        };
27153        assert_eq!(echoed_route, unregistered_route);
27154        assert_eq!(external_entries.len(), 1);
27155        assert_eq!(external_entries[0].record_id, None);
27156        let external_group = external_entries[0]
27157            .external_group
27158            .as_ref()
27159            .expect("unregistered session must have an external group");
27160        assert_eq!(
27161            external_group.kind,
27162            gate4agent_types::NativeSessionExternalGroupKind::Project,
27163        );
27164        assert!(external_group.group_id.starts_with("external-"));
27165        assert_eq!(
27166            external_group.display_name,
27167            outside_root.file_name().unwrap().to_string_lossy(),
27168        );
27169        let external_selection = NativeSessionSelection {
27170            route: unregistered_route,
27171            catalog_revision: external_summary.catalog_revision,
27172            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27173            selection_id: external_entries[0].selection_id.clone(),
27174        };
27175        let external_preview = process_request_inner(
27176            &shared,
27177            7,
27178            ClientRole::Operator,
27179            NodeRequest::PreviewNativeSession {
27180                selection: external_selection.clone(),
27181                message_limit: 1,
27182            },
27183        )
27184        .await
27185        .unwrap();
27186        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27187        assert!(!external_preview_json.contains("outside-session"));
27188        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27189        assert!(!external_preview_json.contains("\"session_id\""));
27190        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27191            panic!("unregistered native preview returned a different response");
27192        };
27193        assert_eq!(preview.messages.len(), 1);
27194        let external_index = process_request_inner(
27195            &shared,
27196            7,
27197            ClientRole::Operator,
27198            NodeRequest::IndexNativeSession {
27199                selection: external_selection,
27200                display_name: "External Claude".to_owned(),
27201            },
27202        )
27203        .await
27204        .unwrap_err();
27205        assert_eq!(
27206            external_index.code,
27207            NodeFailureCode::WorkspaceRegistrationRequired,
27208        );
27209        let record_response = process_request_inner(
27210            &shared,
27211            7,
27212            ClientRole::Operator,
27213            NodeRequest::PreviewSessionRecord {
27214                record_id: record.record_id.clone(),
27215                message_limit: 2,
27216            },
27217        )
27218        .await
27219        .unwrap();
27220        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27221            panic!("record preview returned a different response");
27222        };
27223        assert_eq!(record_id, record.record_id);
27224        assert_eq!(preview.messages.len(), 2);
27225        let encoded = serde_json::to_string(&preview).unwrap();
27226        assert!(!encoded.contains("stable-session"));
27227        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27228        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27229            panic!("record preview history summary returned a different response");
27230        };
27231        let summaries = events
27232            .iter()
27233            .filter_map(|envelope| match &envelope.event {
27234                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27235                    if record_id == &record.record_id =>
27236                {
27237                    Some(*summary)
27238                }
27239                _ => None,
27240            })
27241            .collect::<Vec<_>>();
27242        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27243        let summary_json = serde_json::to_string(&summaries).unwrap();
27244        for private in [
27245            "stable-session",
27246            "raw user text must not cross the catalog wire",
27247            "raw answer",
27248            "Workspace review",
27249            "claude-sonnet",
27250        ] {
27251            assert!(!summary_json.contains(private), "{private}");
27252        }
27253        let identity = record.provider_session.clone().unwrap();
27254        assert_eq!(
27255            shared.revalidate_session_record_preview(&record, &identity),
27256            Ok(())
27257        );
27258        shared.remove_record_memory(&record.record_id);
27259        assert_eq!(
27260            shared
27261                .revalidate_session_record_preview(&record, &identity)
27262                .unwrap_err()
27263                .code,
27264            NodeFailureCode::UnknownSessionRecord
27265        );
27266        let released = process_request_inner(
27267            &shared,
27268            7,
27269            ClientRole::Operator,
27270            NodeRequest::ReleaseController,
27271        )
27272        .await
27273        .unwrap();
27274        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27275        assert!(shared.controller_state().is_none());
27276        std::fs::remove_dir_all(workspace_root).unwrap();
27277        std::fs::remove_dir_all(history_root).unwrap();
27278        std::fs::remove_dir_all(outside_root).unwrap();
27279    }
27280
27281    #[tokio::test]
27282    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27283        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27284        std::fs::create_dir_all(&history_root).unwrap();
27285        let history = NativeHistoryConfig::new(vec![
27286            NativeHistoryRoot::new(
27287                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27288                HistorySourceLayout::SingleNdjson,
27289                history_root.clone(),
27290            )
27291            .unwrap(),
27292        ])
27293        .unwrap();
27294        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27295        let held = catalog.lock().unwrap();
27296        let result = timeout(
27297            Duration::from_secs(1),
27298            run_native_session_catalog_operation(
27299                Arc::clone(&catalog),
27300                "native session catalog",
27301                |_| Ok::<(), std::convert::Infallible>(()),
27302            ),
27303        )
27304        .await
27305        .expect("concurrent native catalog request must not wait for the operation deadline")
27306        .unwrap_err();
27307        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27308        drop(held);
27309        std::fs::remove_dir_all(history_root).unwrap();
27310    }
27311
27312    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27313        let unique = std::time::SystemTime::now()
27314            .duration_since(std::time::UNIX_EPOCH)
27315            .unwrap()
27316            .as_nanos();
27317        std::env::temp_dir().join(format!(
27318            "gate4agent-node-{label}-{}-{unique}",
27319            std::process::id(),
27320        ))
27321    }
27322
27323    fn git_snapshot_for_parser() -> GitSnapshot {
27324        GitSnapshot {
27325            is_repository: true,
27326            branch: Some("main".to_owned()),
27327            status: Vec::new(),
27328            recent_commits: Vec::new(),
27329            worktrees: Vec::new(),
27330            managed_worktree: None,
27331            truncated: false,
27332            diagnostic: None,
27333        }
27334    }
27335
27336    #[tokio::test]
27337    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27338        let root = temporary_workspace_root("managed-git-scope");
27339        std::fs::create_dir_all(&root).unwrap();
27340        run_test_git(&root, &["init"]);
27341        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27342        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27343        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27344        run_test_git(&root, &["add", "seed.txt"]);
27345        run_test_git(&root, &["commit", "-m", "seed"]);
27346        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27347
27348        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27349        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27350        let catalog = active_registry().unwrap();
27351        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27352        let shared = NodeShared::new(
27353            handle,
27354            "fixture-token".to_owned(),
27355            NodeId::new("node-1").unwrap(),
27356            vec![workspace],
27357            vec![agent("claude")],
27358        );
27359        let mut lease = ManagedWorktreeLeaseRecord {
27360            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27361            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27362            workspace_id: workspace_id.clone(),
27363            profile_id: WorktreeProfileId::new("default").unwrap(),
27364            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27365            target_root: root.to_string_lossy().into_owned(),
27366            branch: "gate4agent/a".to_owned(),
27367            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27368            expected_head: None,
27369            retention: ManagedWorktreeRetention::Retain,
27370            state: ManagedWorktreeLeaseState::InUse,
27371            session_holders: vec![ManagedWorktreeSessionHolder {
27372                incarnation_id: shared.incarnation_id,
27373                instance_id: AgentInstanceId(7),
27374                generation: SessionGeneration(1),
27375            }],
27376            record_holders: Vec::new(),
27377            cleanup_failure: None,
27378            created_at_unix_ms: 1,
27379            updated_at_unix_ms: 1,
27380        };
27381        *shared
27382            .managed_worktrees
27383            .lock()
27384            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27385            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27386
27387        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27388        let scope = inspection.git.managed_worktree.unwrap();
27389        assert_eq!(scope.lease_id, lease.lease_id);
27390        assert_eq!(scope.branch, "gate4agent/a");
27391        assert_eq!(scope.active_session_count, 1);
27392
27393        lease.branch = "gate4agent/b".to_owned();
27394        *shared
27395            .managed_worktrees
27396            .lock()
27397            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27398            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27399        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27400        assert!(mismatch.git.managed_worktree.is_none());
27401        std::fs::remove_dir_all(root).unwrap();
27402    }
27403
27404    #[tokio::test]
27405    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27406        let root = temporary_workspace_root("workspace-file-write-controller");
27407        std::fs::create_dir_all(root.join("src")).unwrap();
27408        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27409        let workspace_id = WorkspaceId::new("primary").unwrap();
27410        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27411        let catalog = active_registry().unwrap();
27412        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27413        let shared = NodeShared::new(
27414            handle,
27415            "fixture-token".to_owned(),
27416            NodeId::new("node-1").unwrap(),
27417            vec![workspace],
27418            vec![agent("claude")],
27419        );
27420        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27421        let expected_revision = workspace_file_revision(b"old\n");
27422        let request = NodeRequest::WriteWorkspaceFile {
27423            workspace_id: workspace_id.clone(),
27424            path: path.clone(),
27425            expected_revision: expected_revision.clone(),
27426            text: "new\n".to_owned(),
27427        };
27428        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27429            .await
27430            .unwrap_err();
27431        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27432        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27433            .await
27434            .unwrap_err();
27435        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27436        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27437        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27438        let NodeResponse::WorkspaceFileWritten { file } = response else {
27439            panic!("workspace file write returned another response");
27440        };
27441        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27442        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27443        let stale = process_request_inner(
27444            &shared,
27445            77,
27446            ClientRole::Operator,
27447            NodeRequest::WriteWorkspaceFile {
27448                workspace_id,
27449                path,
27450                expected_revision,
27451                text: "lost update\n".to_owned(),
27452            },
27453        )
27454        .await
27455        .unwrap_err();
27456        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27457        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27458        std::fs::remove_dir_all(root).unwrap();
27459    }
27460
27461    #[tokio::test]
27462    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27463        let root = temporary_workspace_root("workspace-entry-create-controller");
27464        std::fs::create_dir_all(root.join("src")).unwrap();
27465        let workspace_id = WorkspaceId::new("primary").unwrap();
27466        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27467        let catalog = active_registry().unwrap();
27468        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27469        let shared = NodeShared::new(
27470            handle,
27471            "fixture-token".to_owned(),
27472            NodeId::new("node-1").unwrap(),
27473            vec![workspace],
27474            vec![agent("claude")],
27475        );
27476        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27477        let request = NodeRequest::CreateWorkspaceFile {
27478            workspace_id: workspace_id.clone(),
27479            path: path.clone(),
27480        };
27481        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27482            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27483        }));
27484        assert!(!request_uses_unnegotiated_capability(
27485            &request,
27486            &baseline_capabilities().unwrap(),
27487        ));
27488        assert!(request_uses_unnegotiated_capability(&request, &[]));
27489        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27490            .await
27491            .unwrap_err();
27492        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27493        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27494            .await
27495            .unwrap_err();
27496        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27497        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27498
27499        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27500            .await
27501            .unwrap();
27502        let NodeResponse::WorkspaceFileCreated { file } = response else {
27503            panic!("workspace file create returned another response");
27504        };
27505        assert_eq!(file.workspace_id, workspace_id);
27506        assert_eq!(file.path, path);
27507        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27508        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27509
27510        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27511            .await
27512            .unwrap_err();
27513        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27514
27515        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27516        let response = process_request_inner(
27517            &shared,
27518            77,
27519            ClientRole::Operator,
27520            NodeRequest::CreateWorkspaceDirectory {
27521                workspace_id: workspace_id.clone(),
27522                path: directory_path.clone(),
27523            },
27524        )
27525        .await
27526        .unwrap();
27527        assert_eq!(
27528            response,
27529            NodeResponse::WorkspaceDirectoryCreated {
27530                workspace_id: workspace_id.clone(),
27531                entry: WorkspaceEntry {
27532                    relative_path: directory_path,
27533                    kind: WorkspaceEntryKind::Directory,
27534                },
27535            },
27536        );
27537        assert!(root.join("src/nested").is_dir());
27538
27539        let missing_parent = process_request_inner(
27540            &shared,
27541            77,
27542            ClientRole::Operator,
27543            NodeRequest::CreateWorkspaceFile {
27544                workspace_id: workspace_id.clone(),
27545                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27546            },
27547        )
27548        .await
27549        .unwrap_err();
27550        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27551        let non_directory_parent = process_request_inner(
27552            &shared,
27553            77,
27554            ClientRole::Operator,
27555            NodeRequest::CreateWorkspaceFile {
27556                workspace_id,
27557                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27558            },
27559        )
27560        .await
27561        .unwrap_err();
27562        assert_eq!(
27563            non_directory_parent.code,
27564            NodeFailureCode::RepositoryParentNotDirectory,
27565        );
27566        std::fs::remove_dir_all(root).unwrap();
27567    }
27568
27569    #[tokio::test]
27570    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27571        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27572        std::fs::create_dir_all(root.join("src")).unwrap();
27573        #[cfg(windows)]
27574        let root = std::fs::canonicalize(root).unwrap();
27575        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27576        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27577        let barrier = Arc::new(std::sync::Barrier::new(2));
27578        let done = Arc::new(AtomicBool::new(false));
27579        let task_commit_state = Arc::clone(&commit_state);
27580        let task_barrier = Arc::clone(&barrier);
27581        let task_done = Arc::clone(&done);
27582        let task_root = root.clone();
27583        let task = tokio::task::spawn_blocking(move || {
27584            task_barrier.wait();
27585            let result = create_workspace_file_on_disk(
27586                &task_root,
27587                &path,
27588                &task_commit_state,
27589            );
27590            task_done.store(true, Ordering::Release);
27591            result
27592        });
27593
27594        let error = settle_workspace_entry_create(
27595            task,
27596            Arc::clone(&commit_state),
27597            Duration::from_millis(10),
27598            "fixture deadline",
27599            "fixture task failed",
27600        )
27601        .await
27602        .unwrap_err();
27603        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27604        assert_eq!(
27605            commit_state.load(Ordering::Acquire),
27606            WORKSPACE_ENTRY_CREATE_CANCELED,
27607        );
27608
27609        barrier.wait();
27610        timeout(Duration::from_secs(1), async {
27611            while !done.load(Ordering::Acquire) {
27612                tokio::task::yield_now().await;
27613            }
27614        })
27615        .await
27616        .expect("canceled blocking create did not settle");
27617        assert!(!root.join("src/late.rs").exists());
27618        std::fs::remove_dir_all(root).unwrap();
27619    }
27620}
27621
27622#[cfg(test)]
27623mod provisional_spawn_tests {
27624    use super::*;
27625    use std::sync::Arc;
27626
27627    fn shared() -> NodeShared {
27628        let catalog = active_registry().unwrap();
27629        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27630        let workspace = WorkspaceConfig::new(
27631            WorkspaceId::new("primary").unwrap(),
27632            std::env::current_dir().unwrap(),
27633        )
27634        .unwrap()
27635        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27636        NodeShared::new(
27637            handle,
27638            "fixture-token".to_owned(),
27639            NodeId::new("node-provisional-test").unwrap(),
27640            vec![workspace],
27641            vec![AgentId::new("claude").unwrap()],
27642        )
27643    }
27644
27645    /// Provisional draft map: sessionize by instance id only; 50 sequential
27646    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27647    #[test]
27648    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27649        let shared = shared();
27650        let ws = WorkspaceId::new("primary").unwrap();
27651        let mut ids = Vec::with_capacity(50);
27652        for i in 1u64..=50 {
27653            let id = AgentInstanceId(i);
27654            ids.push(id);
27655            let address = SessionAddress {
27656                workspace_id: ws.clone(),
27657                session: SessionKey {
27658                    instance_id: id,
27659                    generation: SessionGeneration(1),
27660                },
27661            };
27662            shared.mark_spawn_provisional(address);
27663        }
27664        assert_eq!(
27665            shared
27666                .provisional_spawns
27667                .lock()
27668                .unwrap_or_else(|p| p.into_inner())
27669                .len(),
27670            50
27671        );
27672        for id in ids.iter().step_by(2) {
27673            shared.sessionize_spawn(*id);
27674        }
27675        assert_eq!(
27676            shared
27677                .provisional_spawns
27678                .lock()
27679                .unwrap_or_else(|p| p.into_inner())
27680                .len(),
27681            25
27682        );
27683        assert!(shared.take_due_provisional_spawns().is_empty());
27684        {
27685            let mut map = shared
27686                .provisional_spawns
27687                .lock()
27688                .unwrap_or_else(|p| p.into_inner());
27689            let past = Instant::now() - Duration::from_secs(30);
27690            for (_, (_, at)) in map.iter_mut() {
27691                *at = past;
27692            }
27693        }
27694        let due = shared.take_due_provisional_spawns();
27695        assert_eq!(due.len(), 25);
27696        let due_ids: std::collections::BTreeSet<_> =
27697            due.iter().map(|a| a.session.instance_id.0).collect();
27698        let expected: std::collections::BTreeSet<_> =
27699            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27700        assert_eq!(due_ids, expected);
27701        assert!(shared
27702            .provisional_spawns
27703            .lock()
27704            .unwrap_or_else(|p| p.into_inner())
27705            .is_empty());
27706    }
27707
27708    /// Twenty parallel provisional marks must each get their own instance key;
27709    /// killing/sessionizing one id must not touch the others.
27710    #[test]
27711    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27712        let shared = Arc::new(shared());
27713        let ws = WorkspaceId::new("primary").unwrap();
27714        let mut handles = Vec::new();
27715        for i in 1u64..=20 {
27716            let shared = Arc::clone(&shared);
27717            let ws = ws.clone();
27718            handles.push(std::thread::spawn(move || {
27719                let id = AgentInstanceId(i);
27720                let address = SessionAddress {
27721                    workspace_id: ws,
27722                    session: SessionKey {
27723                        instance_id: id,
27724                        generation: SessionGeneration(1),
27725                    },
27726                };
27727                shared.mark_spawn_provisional(address);
27728                id
27729            }));
27730        }
27731        let mut ids = Vec::new();
27732        for h in handles {
27733            ids.push(h.join().unwrap());
27734        }
27735        ids.sort_by_key(|id| id.0);
27736        assert_eq!(ids.len(), 20);
27737        ids.dedup();
27738        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27739        assert_eq!(
27740            shared
27741                .provisional_spawns
27742                .lock()
27743                .unwrap_or_else(|p| p.into_inner())
27744                .len(),
27745            20
27746        );
27747        shared.sessionize_spawn(AgentInstanceId(7));
27748        let left: Vec<_> = shared
27749            .provisional_spawns
27750            .lock()
27751            .unwrap_or_else(|p| p.into_inner())
27752            .keys()
27753            .map(|id| id.0)
27754            .collect();
27755        assert!(!left.contains(&7));
27756        assert_eq!(left.len(), 19);
27757    }
27758}
27759